diff --git a/.env.example b/.env.example index e2bbce5..e7c9889 100644 --- a/.env.example +++ b/.env.example @@ -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_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_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_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP) diff --git a/grid-trading.md b/grid-trading.md index e9b7852..2d087f4 100644 --- a/grid-trading.md +++ b/grid-trading.md @@ -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 cp .env.example .env ``` -2. 配置 Aster 交易所 API: +2. 配置交易所 API(以 Aster 为例): ```env EXCHANGE=aster ASTER_API_KEY=你的API密钥 ASTER_API_SECRET=你的API密钥 TRADE_SYMBOL=ASTERUSDT ``` -3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间,20 条网格,单笔 5 手,最大仓位 50 手): +3. 设置精度与网格参数(示例:1.50 ~ 2.50 区间、20 条网格、单笔 5 手、单侧最大 50 手): ```env PRICE_TICK=0.0001 QTY_STEP=0.01 @@ -25,76 +34,144 @@ GRID_LEVELS=20 GRID_ORDER_SIZE=5 GRID_MAX_POSITION_SIZE=50 - GRID_REFRESH_INTERVAL_MS=1000 - GRID_MAX_LOG_ENTRIES=200 GRID_DIRECTION=both GRID_STOP_LOSS_PCT=0.02 - GRID_RESTART_TRIGGER_PCT=0.02 -GRID_AUTO_RESTART_ENABLED=true -GRID_MAX_CLOSE_SLIPPAGE_PCT=0.05 -``` + ``` +4. 启动: + ```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: 为什么只有靠近现价的几个网格有订单? -A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面。 +### Q: 为什么启动后不是一次性挂满所有网格单? +A: 引擎每轮最多新挂 1 笔限价单(按离现价由近到远),既控制请求频率也便于逐单登记意图。以默认 1 秒轮询计,20 条网格约 1 分钟内铺满。 -### Q: 价格突破上界后为何立即平仓? -A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位。 +### Q: 为什么同一价位出现两笔同向挂单? +A: 中性模式的正常形态:一笔是下方线的止盈平仓单,另一笔是该线自身的空头开仓单。价格穿越时两笔都成交,等于「平多 + 开空」。策略内部按意图分别登记,不会混淆。 + +### Q: 平仓单为什么不带 reduceOnly? +A: 部分交易所会拒绝与反向挂单共存的 reduce-only 限价单。策略通过自身的意图登记区分开/平仓,不需要该标志。若你的交易所支持且希望强制,只需设 `GRID_USE_REDUCE_ONLY=true`。 + +### Q: 修改了参数重启后旧挂单怎么办? +A: 若改的是配置指纹字段(方向 / 网格数 / 单笔数量等),策略会全新建格并撤销所有无法归属的旧挂单;平仓方向的旧挂单会被保留为孤儿平仓单继续保护仓位。若只是重启未改参数,挂单和线状态原样恢复,不重复挂单。 ### 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` 或小仓位验证参数与手续费结构,再逐步放大资金规模。 祝交易顺利! diff --git a/src/cli/command-executor.ts b/src/cli/command-executor.ts index aafab96..ff98726 100644 --- a/src/cli/command-executor.ts +++ b/src/cli/command-executor.ts @@ -520,6 +520,7 @@ function resolveEffectiveSymbol(explicit: string | undefined, exchange: Supporte function runtimeCapabilities(adapter: ExchangeAdapter): unknown { return { trailingStops: adapter.supportsTrailingStops(), + triggerOrders: adapter.supportsTriggerOrders?.() ?? false, fundingRate: typeof adapter.watchFundingRate === "function", precision: typeof adapter.getPrecision === "function", queryOpenOrders: typeof adapter.queryOpenOrders === "function", diff --git a/src/config.ts b/src/config.ts index 6c995f2..4f73e6a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -293,6 +293,14 @@ export interface GridConfig { autoRestart: boolean; gridMode: "geometric"; 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 => { @@ -373,6 +381,14 @@ export const gridConfig: GridConfig = { 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); diff --git a/src/exchanges/adapter.ts b/src/exchanges/adapter.ts index a2cdd3c..0561f1b 100644 --- a/src/exchanges/adapter.ts +++ b/src/exchanges/adapter.ts @@ -66,6 +66,8 @@ export interface ConnectionEventListener { export interface ExchangeAdapter { readonly id: string; supportsTrailingStops(): boolean; + /** 是否支持交易所侧触发单(STOP_MARKET 兜底止损),缺省视为 false */ + supportsTriggerOrders?(): boolean; watchAccount(cb: AccountListener): void; watchOrders(cb: OrderListener): void; watchDepth(symbol: string, cb: DepthListener): void; diff --git a/src/exchanges/aster/adapter.ts b/src/exchanges/aster/adapter.ts index 45bd96f..7ab8960 100644 --- a/src/exchanges/aster/adapter.ts +++ b/src/exchanges/aster/adapter.ts @@ -36,6 +36,10 @@ export class AsterExchangeAdapter implements ExchangeAdapter { return true; } + supportsTriggerOrders(): boolean { + return true; + } + watchAccount(cb: AccountListener): void { void this.init.ensureInitialized("watchAccount"); this.gateway.onAccount(this.safeInvoke("watchAccount", (snapshot) => { diff --git a/src/exchanges/binance/adapter.ts b/src/exchanges/binance/adapter.ts index 80bde81..9d8e763 100644 --- a/src/exchanges/binance/adapter.ts +++ b/src/exchanges/binance/adapter.ts @@ -66,6 +66,10 @@ export class BinanceExchangeAdapter implements ExchangeAdapter { return this.marketType !== "spot"; } + supportsTriggerOrders(): boolean { + return this.marketType !== "spot"; + } + watchAccount(cb: AccountListener): void { const safe = this.safeInvoke("watchAccount", cb); void this.init.ensureInitialized("watchAccount") diff --git a/src/exchanges/dry-run-adapter.ts b/src/exchanges/dry-run-adapter.ts index a2d2ec0..61f44b6 100644 --- a/src/exchanges/dry-run-adapter.ts +++ b/src/exchanges/dry-run-adapter.ts @@ -41,6 +41,10 @@ export class DryRunExchangeAdapter implements ExchangeAdapter { return this.inner.supportsTrailingStops(); } + supportsTriggerOrders(): boolean { + return this.inner.supportsTriggerOrders?.() ?? false; + } + watchAccount(cb: AccountListener): void { this.inner.watchAccount(cb); } diff --git a/src/exchanges/grvt/adapter.ts b/src/exchanges/grvt/adapter.ts index 7f67bc0..8b05f44 100644 --- a/src/exchanges/grvt/adapter.ts +++ b/src/exchanges/grvt/adapter.ts @@ -96,6 +96,10 @@ export class GrvtExchangeAdapter implements ExchangeAdapter { return false; } + supportsTriggerOrders(): boolean { + return true; + } + watchAccount(cb: AccountListener): void { void this.init.ensureInitialized("watchAccount"); this.gateway.onAccount(this.safeInvoke("watchAccount", cb)); diff --git a/src/exchanges/ondoperps/adapter.ts b/src/exchanges/ondoperps/adapter.ts index 2f17ae7..2928fa6 100644 --- a/src/exchanges/ondoperps/adapter.ts +++ b/src/exchanges/ondoperps/adapter.ts @@ -59,6 +59,10 @@ export class OndoperpsExchangeAdapter implements ExchangeAdapter { return false; } + supportsTriggerOrders(): boolean { + return true; + } + watchAccount(cb: AccountListener): void { void this.init.ensureInitialized("watchAccount"); this.gateway.onAccount(this.safeInvoke("watchAccount", cb)); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index d72f0d2..506dc43 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -288,6 +288,16 @@ const translations: Record = { en: "Last price: {lastPrice} | Lower: {lower} | Upper: {upper} | Grid count: {count}", }, "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.configTitle": { zh: "网格配置", en: "Grid Config" }, "grid.configSize": { diff --git a/src/strategy/common/grid-storage.ts b/src/strategy/common/grid-storage.ts index ea7f275..3b10303 100644 --- a/src/strategy/common/grid-storage.ts +++ b/src/strategy/common/grid-storage.ts @@ -1,41 +1,80 @@ import { promises as fs } from "fs"; 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"); -const GRID_FILE = path.resolve(DATA_DIR, "grid-record.json"); +export type { LevelPhase, StoredGridStateV2, StoredLevelV2 }; -/** State of a single grid level */ -export type LevelState = "idle" | "filled" | "exit_placed"; - -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; +// 惰性解析,测试可通过 GRID_DATA_DIR 切换目录 +function dataDir(): string { + return process.env.GRID_DATA_DIR?.trim() || path.resolve("data"); } -export interface StoredGridState { +function gridFile(): string { + return path.resolve(dataDir(), "grid-record.json"); +} + +/** v1 遗留格式(无 schemaVersion 字段) */ +interface StoredGridStateV1 { symbol: string; lowerPrice: number; upperPrice: number; gridLevels: number; orderSize: number; maxPositionSize: number; - direction: GridDirection; - /** Per-level state: key is level index string */ - levels: Record; + direction: string; + levels: Record< + string, + { state: "idle" | "filled" | "exit_placed"; sourceLevel: number; targetLevel: number | null; exitOrderId?: string } + >; updatedAt: number; } -type GridStateMap = Record; +type StoredGridStateAny = StoredGridStateV1 | StoredGridStateV2; +type GridStateMap = Record; + +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 = {}; + 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 { try { - await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.mkdir(dataDir(), { recursive: true }); } catch { // ignore } @@ -43,7 +82,7 @@ async function ensureDataDir(): Promise { async function readStateFile(): Promise { try { - const content = await fs.readFile(GRID_FILE, "utf8"); + const content = await fs.readFile(gridFile(), "utf8"); const parsed = JSON.parse(content); if (parsed && typeof parsed === "object") { return parsed as GridStateMap; @@ -57,17 +96,19 @@ async function readStateFile(): Promise { } } -export async function loadGridState(symbol: string): Promise { +export async function loadGridState(symbol: string): Promise { const map = await readStateFile(); 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 { +export async function saveGridState(snapshot: StoredGridStateV2): Promise { await ensureDataDir(); const map = await readStateFile(); 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 { @@ -79,7 +120,7 @@ export async function clearGridState(symbol: string): Promise { const entries = Object.keys(map); if (!entries.length) { try { - await fs.unlink(GRID_FILE); + await fs.unlink(gridFile()); } catch (error: any) { if (!error || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) { throw error; @@ -88,5 +129,5 @@ export async function clearGridState(symbol: string): Promise { return; } await ensureDataDir(); - await fs.writeFile(GRID_FILE, JSON.stringify(map, null, 2), "utf8"); + await fs.writeFile(gridFile(), JSON.stringify(map, null, 2), "utf8"); } diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index c61f4cd..0ecc9af 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -2,13 +2,13 @@ import type { GridConfig, GridDirection } from "../config"; import type { ExchangeAdapter } from "../exchanges/adapter"; import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types"; import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; -import { decimalsOf } from "../utils/math"; -import { extractMessage } from "../utils/errors"; +import { extractMessage, isUnknownOrderError } from "../utils/errors"; import { getMidOrLast } from "../utils/price"; import { getPosition, type PositionSnapshot } from "../utils/strategy"; import { - placeMarketOrder, + marketClose, placeOrder, + placeStopLossOrder, unlockOperating, type OrderLockMap, type OrderPendingMap, @@ -16,43 +16,54 @@ import { } from "../core/order-coordinator"; import { StrategyEventEmitter } from "./common/event-emitter"; import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage"; import { - loadGridState, - saveGridState, - clearGridState, - type StoredGridState, - type StoredLevelInfo, - type LevelState, -} from "./common/grid-storage"; + ORPHAN_LEVEL, + applyRebuild, + createInitialState, + desiredExchangeStop, + fromStored, + isCompatibleStoredState, + makeEntryClientOrderId, + makeExitClientOrderId, + planShiftStep, + planTick, + qtyEpsilon, + reconcile, + toStored, + type ExchangeStopState, + type GridLogicSettings, + type GridLogicState, + type GridPlanAction, + type GridTradeMode, + type LevelPhase, + type OrderIntentRecord, + type OrderView, + type ShiftPhase, + type Side, + type StateMeta, +} from "./grid-logic"; // --------------------------------------------------------------------------- -// Types +// Snapshot types // --------------------------------------------------------------------------- -interface DesiredGridOrder { +export interface DesiredGridOrder { level: number; - side: "BUY" | "SELL"; + side: Side; price: string; amount: number; intent: "ENTRY" | "EXIT"; - reduceOnly?: boolean; } -interface LevelMeta { - index: number; - price: number; - side: "BUY" | "SELL"; - closeTarget: number | null; - closeSources: number[]; -} - -interface GridLineSnapshot { +export interface GridLineSnapshot { level: number; price: number; - side: "BUY" | "SELL"; - active: boolean; + side: Side | "-"; + role: "entry-buy" | "entry-sell" | "none"; + state: LevelPhase; hasOrder: boolean; - state: LevelState; + holdQty: number; } export interface GridEngineSnapshot { @@ -60,6 +71,9 @@ export interface GridEngineSnapshot { symbol: string; lowerPrice: number; upperPrice: number; + gridVersion: number; + anchorPrice: number | null; + shiftPhase: ShiftPhase | null; lastPrice: number | null; midPrice: number | null; gridLines: GridLineSnapshot[]; @@ -69,6 +83,10 @@ export interface GridEngineSnapshot { running: boolean; stopReason: string | null; direction: GridDirection; + stopProtection: { + uncoveredQty: number; + exchangeStop: ExchangeStopState | null; + }; tradeLog: TradeLogEntry[]; feedStatus: { account: boolean; @@ -88,159 +106,90 @@ interface EngineOptions { skipPersistence?: boolean; } -// --------------------------------------------------------------------------- -// clientOrderId encoding/decoding -// --------------------------------------------------------------------------- - -const CID_PREFIX = "grid"; - -/** ENTRY: grid-E-{level}-{tsHex} EXIT: grid-X-{sourceLevel}-{targetLevel}-{tsHex} */ -function makeClientOrderId(intent: "ENTRY" | "EXIT", level: number, targetOrSource?: number): string { - const hex = Date.now().toString(16); - if (intent === "ENTRY") return `${CID_PREFIX}-E-${level}-${hex}`; - return `${CID_PREFIX}-X-${targetOrSource ?? 0}-${level}-${hex}`; -} - -interface ParsedClientOrderId { - intent: "ENTRY" | "EXIT"; - level: number; - sourceLevel?: number; -} - -function parseClientOrderId(cid: string): ParsedClientOrderId | null { - if (!cid || !cid.startsWith(`${CID_PREFIX}-`)) return null; - const parts = cid.split("-"); - // grid-E-{level}-{hex} - if (parts[1] === "E" && parts.length >= 3) { - const level = Number(parts[2]); - if (!Number.isFinite(level)) return null; - return { intent: "ENTRY", level }; - } - // grid-X-{sourceLevel}-{targetLevel}-{hex} - if (parts[1] === "X" && parts.length >= 4) { - const sourceLevel = Number(parts[2]); - const targetLevel = Number(parts[3]); - if (!Number.isFinite(sourceLevel) || !Number.isFinite(targetLevel)) return null; - return { intent: "EXIT", level: targetLevel, sourceLevel }; - } - return null; -} - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - const EPSILON = 1e-8; +const FINAL_STATUSES = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]); // --------------------------------------------------------------------------- -// GridEngine +// GridEngine:I/O 编排层。所有网格决策在 grid-logic.ts 的纯函数中完成。 // --------------------------------------------------------------------------- export class GridEngine { + static readonly LIMIT_COOLDOWN_MS = 3000; + static readonly STOP_SYNC_INTERVAL_MS = 5000; + static readonly STALE_PLACEMENT_MS = 20_000; + private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); private readonly locks: OrderLockMap = {}; private readonly timers: OrderTimerMap = {}; private readonly pendings: OrderPendingMap = {}; - private priceDecimals: number; private readonly now: () => number; - private readonly configValid: boolean; - private readonly gridLevels: number[]; - private readonly levelMeta: LevelMeta[] = []; - private readonly buyLevelIndices: number[] = []; - private readonly sellLevelIndices: number[] = []; private readonly skipPersistence: boolean; + private readonly configValid: boolean; + private readonly log: LogHandler; - // --- Per-level state tracking --- - // Each grid level can be: idle → filled → exit_placed → idle (cycle) - // A level in "filled" or "exit_placed" state CANNOT accept a new ENTRY order. - private readonly levelStates = new Map(); - // Maps source level → target level for EXIT orders - private readonly exitTargetBySource = new Map(); - // Maps order id → parsed intent for tracking active orders - private readonly orderIntentById = new Map(); + private state: GridLogicState | null = null; + private initStarted = false; + private initDone = false; - // Deferred disappearance classification - private readonly awaitingByLevel = new Map(); - - // Order key suppression to bridge WS latency - private readonly pendingKeyUntil = new Map(); - static readonly PENDING_TTL_MS = 10_000; - - private prevActiveIds = new Set(); - private sidesLocked = false; - private recoveryDone = false; - private recoveryPromise: Promise | null = null; - private lastAbsPositionAmt = 0; - private immediateCloseToPlace: Array<{ sourceLevel: number; targetLevel: number; side: "BUY" | "SELL"; price: string }> = []; - - // Legacy compatibility maps kept for tests calling computeDesiredOrders/syncGrid - private readonly longExposure = new Map(); - private readonly shortExposure = new Map(); - - private accountSnapshot: AccountSnapshot | null = null; private depthSnapshot: Depth | null = null; private tickerSnapshot: Ticker | null = null; private openOrders: Order[] = []; - - private position: PositionSnapshot = { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null }; - private desiredOrders: DesiredGridOrder[] = []; - - private readonly feedArrived = { - account: false, - orders: false, - depth: false, - ticker: false, + private position: PositionSnapshot = { + positionAmt: 0, + entryPrice: 0, + unrealizedProfit: 0, + markPrice: null, }; - private readonly feedStatus = { - account: false, - orders: false, - depth: false, - ticker: false, - }; + private readonly feedStatus = { account: false, orders: false, depth: false, ticker: false }; + private readonly feedArrived = { account: false, orders: false, depth: false, ticker: false }; - private readonly log: LogHandler; - private precisionSync: Promise | null = null; + private accountVersion = 0; + private ordersVersion = 0; + private ordersFeedLastAt = 0; + private tickerLastAt = 0; + + private frozen = false; + private restReconcilePending = false; + private lastReconcileAt = 0; + private lastStopSyncAt = 0; + private stopPlacedAt = 0; + private lastPlacementAt = 0; + private lastPlacementOrdersVersion = -1; + private lastLimitAttemptAt = 0; + private lastStalenessLogAt = 0; + private shiftCloseAccountVersion = -1; + private shiftCloseAt = 0; private timer: ReturnType | null = null; private processing = false; private running: boolean; private stopReason: string | null = null; private lastUpdated: number | null = null; - private accountVersion = 0; - private ordersVersion = 0; - private lastPlacementOrdersVersion = -1; - private lastLimitAttemptAt = 0; - static readonly LIMIT_COOLDOWN_MS = 3000; private savePending = false; + private uncoveredQty = 0; + private desiredOrders: DesiredGridOrder[] = []; + private precisionSync: Promise | null = null; - constructor(private readonly config: GridConfig, private readonly exchange: ExchangeAdapter, options: EngineOptions = {}) { + constructor( + private readonly config: GridConfig, + private readonly exchange: ExchangeAdapter, + options: EngineOptions = {} + ) { this.tradeLog = createTradeLog(this.config.maxLogEntries); this.log = (type, detail) => this.tradeLog.push(type, detail); - this.priceDecimals = decimalsOf(this.config.priceTick); this.now = options.now ?? Date.now; this.skipPersistence = options.skipPersistence ?? false; this.configValid = this.validateConfig(); - this.gridLevels = this.computeGridLevels(); - this.buildLevelMeta(); - this.syncPrecision(); this.running = this.configValid; if (!this.configValid) { this.stopReason = "配置无效,已暂停网格"; this.log("error", this.stopReason); } - if (this.gridLevels.length === 0) { - this.running = false; - this.stopReason = `网格价位计算失败,模式不支持或参数无效: ${String(this.config.gridMode)}`; - this.log("error", this.stopReason); - this.emitUpdate(); - } - // Initialize all levels to idle - for (let i = 0; i < this.gridLevels.length; i++) { - this.levelStates.set(i, "idle"); - } + this.syncPrecision(); this.bootstrap(); + this.setupConnectionProtection(); } start(): void { @@ -274,6 +223,56 @@ export class GridEngine { return this.buildSnapshot(); } + // ----------------------------------------------------------------------- + // Settings / validation + // ----------------------------------------------------------------------- + + private validateConfig(): boolean { + if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) return false; + if (this.config.upperPrice <= this.config.lowerPrice) return false; + if (!Number.isFinite(this.config.gridLevels) || this.config.gridLevels < 2) return false; + if (!Number.isFinite(this.config.orderSize) || this.config.orderSize <= 0) return false; + if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) return false; + if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 1) return false; + if (this.config.gridMode !== "geometric") return false; + return true; + } + + private get tradeMode(): GridTradeMode { + return this.config.direction === "both" ? "neutral" : this.config.direction; + } + + private logicSettings(): GridLogicSettings { + return { + direction: this.tradeMode, + lowerPrice: this.state?.lowerPrice ?? this.config.lowerPrice, + upperPrice: this.state?.upperPrice ?? this.config.upperPrice, + gridLevels: this.config.gridLevels, + orderSize: this.config.orderSize, + maxPositionSize: this.config.maxPositionSize, + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep, + stopLossPct: this.config.stopLossPct, + uncoveredGraceMs: this.config.uncoveredGraceMs, + shiftEnabled: this.config.gridShiftEnabled, + shiftTriggerPct: this.config.gridShiftTriggerPct, + shiftRangePct: this.config.gridShiftRangePct, + shiftConfirmMs: this.config.gridShiftConfirmMs, + }; + } + + private stateMeta(): StateMeta { + return { + symbol: this.config.symbol, + exchangeId: this.exchange.id, + direction: this.tradeMode, + orderSize: this.config.orderSize, + maxPositionSize: this.config.maxPositionSize, + gridLevels: this.config.gridLevels, + gridMode: this.config.gridMode, + }; + } + // ----------------------------------------------------------------------- // Precision sync // ----------------------------------------------------------------------- @@ -289,7 +288,6 @@ export class GridEngine { if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { if (Math.abs(precision.priceTick - this.config.priceTick) > 1e-12) { this.config.priceTick = precision.priceTick; - this.priceDecimals = decimalsOf(precision.priceTick); updated = true; } } @@ -301,7 +299,6 @@ export class GridEngine { } if (updated) { this.log("info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`); - this.rebuildGridAfterPrecisionUpdate(); } }) .catch((error) => { @@ -311,60 +308,24 @@ export class GridEngine { }); } - private rebuildGridAfterPrecisionUpdate(): void { - if (!this.configValid) return; - const reference = this.getReferencePrice(); - const newLevels = this.computeGridLevels(); - this.gridLevels.length = 0; - this.gridLevels.push(...newLevels); - this.buildLevelMeta(reference); - // Re-initialize level states - for (let i = 0; i < this.gridLevels.length; i++) { - if (!this.levelStates.has(i)) { - this.levelStates.set(i, "idle"); - } - } - this.emitUpdate(); - } - // ----------------------------------------------------------------------- - // Validation - // ----------------------------------------------------------------------- - - private validateConfig(): boolean { - if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) return false; - if (this.config.upperPrice <= this.config.lowerPrice) return false; - if (!Number.isFinite(this.config.gridLevels) || this.config.gridLevels < 2) return false; - if (!Number.isFinite(this.config.orderSize) || this.config.orderSize <= 0) return false; - if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) return false; - if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 1) return false; - return true; - } - - // ----------------------------------------------------------------------- - // Bootstrap / Feed subscriptions + // Feed subscriptions / connection events // ----------------------------------------------------------------------- private bootstrap(): void { - const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); - safeSubscribe( this.exchange.watchAccount.bind(this.exchange), (snapshot) => { - this.accountSnapshot = snapshot; this.position = getPosition(snapshot, this.config.symbol); - this.syncLegacyExposureFromPosition(); this.accountVersion += 1; - this.lastAbsPositionAmt = Math.abs(this.position.positionAmt); if (!this.feedArrived.account) { this.feedArrived.account = true; - log("info", "账户快照已同步"); + this.log("info", "账户快照已同步"); } this.feedStatus.account = true; - this.tryLockSidesOnce(); this.emitUpdate(); }, - log, + this.log, { subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`, processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`, @@ -377,19 +338,18 @@ export class GridEngine { this.openOrders = Array.isArray(orders) ? orders.filter((order) => order.symbol === this.config.symbol) : []; - this.synchronizeLocks(orders); + this.synchronizeLocks(this.openOrders); this.ordersVersion += 1; + this.ordersFeedLastAt = this.now(); if (!this.feedArrived.orders) { this.feedArrived.orders = true; - log("info", "订单快照已同步"); - // Trigger recovery from existing orders + persisted state - this.recoveryPromise = this.recoverState(); + this.log("info", "订单快照已同步"); } this.feedStatus.orders = true; - this.tryLockSidesOnce(); + void this.attemptInit(); this.emitUpdate(); }, - log, + this.log, { subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`, processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`, @@ -402,12 +362,11 @@ export class GridEngine { this.depthSnapshot = depth; if (!this.feedArrived.depth) { this.feedArrived.depth = true; - log("info", "盘口深度已同步"); + this.log("info", "盘口深度已同步"); } this.feedStatus.depth = true; - this.tryLockSidesOnce(); }, - log, + this.log, { subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`, processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`, @@ -418,15 +377,16 @@ export class GridEngine { this.exchange.watchTicker.bind(this.exchange, this.config.symbol), (ticker) => { this.tickerSnapshot = ticker; + this.tickerLastAt = this.now(); if (!this.feedArrived.ticker) { this.feedArrived.ticker = true; - log("info", "行情推送已同步"); + this.log("info", "行情推送已同步"); } this.feedStatus.ticker = true; - this.tryLockSidesOnce(); + void this.attemptInit(); this.emitUpdate(); }, - log, + this.log, { subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`, processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`, @@ -434,9 +394,23 @@ export class GridEngine { ); } + private setupConnectionProtection(): void { + if (!this.exchange.onConnectionEvent) return; + this.exchange.onConnectionEvent((event, symbol) => { + if (event === "disconnected") { + this.frozen = true; + this.log("warn", `WebSocket 断连 (${symbol}),冻结网格下单`); + } else if (event === "reconnected") { + this.frozen = false; + this.restReconcilePending = true; + this.log("info", `WebSocket 重连成功 (${symbol}),下一轮执行对账`); + } + this.emitUpdate(); + }); + } + private synchronizeLocks(orders: Order[] | null | undefined): void { const list = Array.isArray(orders) ? orders : []; - const FINAL = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]); Object.keys(this.pendings).forEach((type) => { const pendingId = this.pendings[type]; if (!pendingId) return; @@ -446,185 +420,170 @@ export class GridEngine { return; } const status = String(match.status || "").toUpperCase(); - if (FINAL.has(status)) { + if (FINAL_STATUSES.has(status)) { unlockOperating(this.locks, this.timers, this.pendings, type); } }); } // ----------------------------------------------------------------------- - // Recovery: reconstruct level states from open orders + disk + position + // Order helpers // ----------------------------------------------------------------------- - private async recoverState(): Promise { - if (this.recoveryDone) return; - this.recoveryDone = true; + private isExchangeStopOrder(order: Order): boolean { + if (order.symbol !== this.config.symbol) return false; + const type = String(order.type || "").toUpperCase(); + if (type.includes("STOP")) return true; + const stopPrice = Number(order.stopPrice); + return Number.isFinite(stopPrice) && stopPrice > 0; + } - // 1) Load persisted state from disk - let persisted: StoredGridState | null = null; - if (!this.skipPersistence) { - try { - persisted = await loadGridState(this.config.symbol); - } catch (err) { - this.log("error", `加载网格状态失败: ${extractMessage(err)}`); - } + private isActiveGridLimitOrder(order: Order): boolean { + if (order.symbol !== this.config.symbol) return false; + if (order.type !== "LIMIT") return false; + if (this.isExchangeStopOrder(order)) return false; + if (this.state?.exchangeStop && String(order.orderId) === this.state.exchangeStop.orderId) { + return false; } + const status = String(order.status || "").toUpperCase(); + return !FINAL_STATUSES.has(status); + } - // 2) Check if persisted state matches current config - const configMatch = persisted && - persisted.lowerPrice === this.config.lowerPrice && - persisted.upperPrice === this.config.upperPrice && - persisted.gridLevels === this.config.gridLevels; + private activeGridLimitOrders(source?: Order[]): Order[] { + return (source ?? this.openOrders).filter((order) => this.isActiveGridLimitOrder(order)); + } - // 3) Restore level states from persisted data if config matches - if (configMatch && persisted) { - let restored = 0; - for (const [key, info] of Object.entries(persisted.levels)) { - const idx = Number(key); - if (!Number.isFinite(idx) || idx < 0 || idx >= this.gridLevels.length) continue; - if (info.state === "filled" || info.state === "exit_placed") { - this.levelStates.set(idx, info.state); - if (info.targetLevel != null) { - this.exitTargetBySource.set(idx, info.targetLevel); - } - restored++; + private toOrderView(order: Order): OrderView { + return { + orderId: String(order.orderId), + clientOrderId: order.clientOrderId || undefined, + side: order.side, + price: Number(order.price), + status: String(order.status || ""), + executedQty: Number(order.executedQty || 0), + origQty: Number(order.origQty || 0), + type: String(order.type || ""), + }; + } + + private symbolOrderViews(source?: Order[]): OrderView[] { + return (source ?? this.openOrders) + .filter((order) => order.symbol === this.config.symbol && !this.isExchangeStopOrder(order)) + .map((order) => this.toOrderView(order)); + } + + private getReferencePrice(): number | null { + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); + } + + private isReady(): boolean { + return this.feedStatus.account && this.feedStatus.orders && this.feedStatus.ticker; + } + + // ----------------------------------------------------------------------- + // 初始化:磁盘恢复 + 启动对账 + // ----------------------------------------------------------------------- + + private async attemptInit(): Promise { + if (this.state || this.initStarted || !this.configValid) return; + if (!this.feedStatus.orders || !this.feedStatus.account) return; + const price = this.getReferencePrice(); + if (price == null || !Number.isFinite(price)) return; + this.initStarted = true; + try { + let stored = null; + if (!this.skipPersistence) { + try { + stored = await loadGridState(this.config.symbol); + } catch (err) { + this.log("error", `加载网格状态失败: ${extractMessage(err)}`); } } - if (restored > 0) { - this.log("info", `从磁盘恢复了 ${restored} 条网格等级状态`); - } - } - - // 4) Parse open orders' clientOrderId to reconstruct intent tracking - const activeOrders = this.openOrders.filter(o => this.isActiveLimitOrder(o)); - let recognized = 0; - for (const o of activeOrders) { - const cid = o.clientOrderId; - const parsed = parseClientOrderId(cid); - if (!parsed) continue; - if (parsed.level < 0 || parsed.level >= this.gridLevels.length) continue; - - const id = String(o.orderId); - if (parsed.intent === "ENTRY") { - this.orderIntentById.set(id, { - side: o.side, - price: this.normalizePrice(o.price), - level: parsed.level, - intent: "ENTRY", - }); + const meta = this.stateMeta(); + if (stored && isCompatibleStoredState(stored, meta)) { + this.state = fromStored(stored, this.logicSettings(), price); + this.log( + "info", + `已从磁盘恢复网格状态: gridVersion=${this.state.gridVersion} anchor=${this.state.anchorPrice} ` + + `区间=[${this.state.lowerPrice}, ${this.state.upperPrice}]${this.state.shift ? ` 移格续跑(${this.state.shift.phase})` : ""}` + ); } else { - const src = parsed.sourceLevel ?? 0; - this.orderIntentById.set(id, { - side: o.side, - price: this.normalizePrice(o.price), - level: parsed.level, - intent: "EXIT", - sourceLevel: src, - }); - // Source level should be at least "filled" since an EXIT exists for it - if (this.levelStates.get(src) === "idle") { - this.levelStates.set(src, "exit_placed"); + if (stored) { + this.log("warn", "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描"); } - this.exitTargetBySource.set(src, parsed.level); + this.state = createInitialState(this.logicSettings(), price); + this.log("info", `以锚定价 ${this.state.anchorPrice} 建立网格 (${this.tradeMode})`); } - recognized++; + await this.applyReconcile(this.openOrders, "startup"); + this.initDone = true; + } catch (err) { + this.log("error", `网格初始化失败: ${extractMessage(err)}`); + this.initStarted = false; } - - // 5) If we have a net position but no level is in filled/exit_placed state, - // infer from position which levels should be marked filled - const absPos = Math.abs(this.position.positionAmt); - if (absPos > EPSILON) { - const filledOrExiting = this.countNonIdleLevels(); - if (filledOrExiting === 0) { - this.inferLevelStatesFromPosition(); - } - } - - // 6) Cancel stale ENTRY orders that don't match any idle level - // (leftover from a crashed prior run with different grid params) - const staleOrderIds: Array = []; - for (const o of activeOrders) { - const cid = o.clientOrderId; - const parsed = parseClientOrderId(cid); - if (!parsed) { - // Unknown order — not placed by this grid engine. Cancel it. - staleOrderIds.push(o.orderId); - continue; - } - if (parsed.intent === "ENTRY") { - const state = this.levelStates.get(parsed.level); - if (state !== "idle") { - // This level is already filled or has an exit; stale ENTRY - staleOrderIds.push(o.orderId); - } - } - } - - if (staleOrderIds.length > 0) { - try { - await this.exchange.cancelOrders({ symbol: this.config.symbol, orderIdList: staleOrderIds }); - this.log("order", `恢复阶段:撤销 ${staleOrderIds.length} 个过时挂单`); - } catch (err) { - this.log("error", `恢复阶段撤单失败: ${extractMessage(err)}`); - } - } - - // Initialize prevActiveIds from current orders to avoid false disappearances on first tick - this.prevActiveIds = new Set( - this.openOrders.filter(o => this.isActiveLimitOrder(o)).map(o => String(o.orderId)) - ); - - if (recognized > 0 || (configMatch && persisted)) { - this.log("info", `恢复完成: 识别 ${recognized} 个订单, 非空闲等级 ${this.countNonIdleLevels()} 个`); - } else { - this.log("info", "无历史状态可恢复,从零开始部网"); - } - this.emitUpdate(); } - private countNonIdleLevels(): number { - let count = 0; - for (const [, state] of this.levelStates) { - if (state !== "idle") count++; + // ----------------------------------------------------------------------- + // 对账(重启 / 重连 / 周期 REST) + // ----------------------------------------------------------------------- + + private async applyReconcile(orders: Order[], source: string): Promise { + const state = this.state; + if (!state) return; + const result = reconcile(state, this.logicSettings(), { + activeOrders: this.activeGridLimitOrders(orders).map((o) => this.toOrderView(o)), + positionAmt: this.position.positionAmt, + price: this.getReferencePrice(), + now: this.now(), + }); + for (const event of result.events) { + this.log("info", `[对账:${source}] ${event}`); } - return count; + if (result.cancelOrderIds.length > 0) { + try { + await this.exchange.cancelOrders({ + symbol: this.config.symbol, + orderIdList: result.cancelOrderIds, + }); + this.log("order", `[对账:${source}] 撤销 ${result.cancelOrderIds.length} 个无法归属的挂单`); + } catch (err) { + if (!isUnknownOrderError(err)) { + this.log("error", `[对账:${source}] 撤单失败: ${extractMessage(err)}`); + } + } + } + this.lastReconcileAt = this.now(); + await this.persistNow(); } - /** When no persisted state but position exists, infer which levels should be "filled" */ - private inferLevelStatesFromPosition(): void { - const qty = this.position.positionAmt; - if (Math.abs(qty) <= EPSILON) return; - const entry = this.position.entryPrice; - - if (qty > 0) { - // Long position — mark nearest BUY levels as filled - let remaining = Math.abs(qty); - const candidates = this.buyLevelIndices.slice().reverse(); - for (const level of candidates) { - if (remaining <= EPSILON) break; - this.levelStates.set(level, "filled"); - const target = this.levelMeta[level]?.closeTarget; - if (target != null) { - this.exitTargetBySource.set(level, target); - } - remaining -= this.config.orderSize; + private async runRestReconcile(source: string): Promise { + if (!this.state) return; + let orders: Order[] | null = null; + if (this.exchange.queryOpenOrders) { + try { + const fetched = await this.exchange.queryOpenOrders(); + orders = fetched.filter((order) => order.symbol === this.config.symbol); + } catch (err) { + this.log("error", `[对账:${source}] REST 查询挂单失败: ${extractMessage(err)}`); } - this.log("info", `根据多头仓位推断 ${Math.abs(qty)} 个网格等级为已成交`); - } else { - let remaining = Math.abs(qty); - const candidates = this.sellLevelIndices.slice(); - for (const level of candidates) { - if (remaining <= EPSILON) break; - this.levelStates.set(level, "filled"); - const target = this.levelMeta[level]?.closeTarget; - if (target != null) { - this.exitTargetBySource.set(level, target); - } - remaining -= this.config.orderSize; - } - this.log("info", `根据空头仓位推断 ${Math.abs(qty)} 个网格等级为已成交`); } + if (this.exchange.queryAccountSnapshot) { + try { + const snapshot = await this.exchange.queryAccountSnapshot(); + if (snapshot) { + this.position = getPosition(snapshot, this.config.symbol); + this.accountVersion += 1; + } + } catch (err) { + this.log("error", `[对账:${source}] REST 查询账户失败: ${extractMessage(err)}`); + } + } + if (orders) { + this.openOrders = orders; + this.ordersVersion += 1; + this.ordersFeedLastAt = this.now(); + } + await this.applyReconcile(orders ?? this.openOrders, source); } // ----------------------------------------------------------------------- @@ -635,26 +594,70 @@ export class GridEngine { if (this.processing) return; this.processing = true; try { - this.tryLockSidesOnce(); if (!this.running) { await this.tryRestart(); return; } if (!this.isReady()) return; - // Wait for recovery before grid operations - if (!this.recoveryDone) { - if (this.recoveryPromise) { - try { await this.recoveryPromise; } catch {} - } - if (!this.recoveryDone) return; + if (!this.initDone) { + await this.attemptInit(); + if (!this.initDone) return; } + if (!this.state) return; + if (this.frozen) return; + + if (this.restReconcilePending) { + this.restReconcilePending = false; + await this.runRestReconcile("reconnect"); + } else if ( + this.exchange.queryOpenOrders && + this.now() - this.lastReconcileAt >= this.config.reconcileIntervalMs + ) { + await this.runRestReconcile("periodic"); + } + const price = this.getReferencePrice(); - if (!Number.isFinite(price) || price === null) return; - if (this.shouldStop(price)) { - await this.haltGrid(price); + if (price == null || !Number.isFinite(price)) return; + + if (this.state.shift) { + await this.runShiftStep(price); return; } - await this.syncGridSimple(price); + + const input = { + now: this.now(), + price, + positionAmt: this.position.positionAmt, + entryPrice: Number(this.position.entryPrice) || 0, + accountVersion: this.accountVersion, + activeOrders: this.activeGridLimitOrders().map((o) => this.toOrderView(o)), + allOrders: this.symbolOrderViews(), + }; + const plan = planTick(this.state, this.logicSettings(), input); + for (const event of plan.events) { + this.log("info", event); + } + this.uncoveredQty = plan.uncoveredQty; + this.desiredOrders = plan.actions + .filter((a): a is Extract => + a.kind === "PLACE_ENTRY" || a.kind === "PLACE_EXIT" + ) + .map((a) => + a.kind === "PLACE_ENTRY" + ? { level: a.level, side: a.side, price: a.price, amount: a.qty, intent: "ENTRY" as const } + : { level: a.target ?? ORPHAN_LEVEL, side: a.side, price: a.price, amount: a.qty, intent: "EXIT" as const } + ); + + await this.executeActions(plan.actions); + // halt / 移格启动后不再执行后续挂单与持久化(halt 已清盘) + if (!this.state || !this.running || this.state.shift) return; + + await this.syncExchangeStop(price); + + this.lastUpdated = this.now(); + if (plan.stateChanged) { + this.schedulePersist(); + } } catch (error) { this.log("error", `网格轮询异常: ${extractMessage(error)}`); } finally { @@ -663,89 +666,170 @@ export class GridEngine { } } - private isReady(): boolean { - return this.feedStatus.account && this.feedStatus.orders && this.feedStatus.ticker; + private async executeActions(actions: GridPlanAction[]): Promise { + let limitPlaced = false; + for (const action of actions) { + if (action.kind === "HALT") { + await this.haltGrid(action.reason); + return; + } + if (action.kind === "BEGIN_SHIFT") { + // 移格标记已由 planTick 写入 state,落盘后由下个 tick 开始执行 + this.log("warn", `启动智能移格,目标锚定价 ${action.targetAnchor}`); + await this.persistNow(); + return; + } + if (action.kind === "MARKET_CLOSE") { + await this.guardedMarketClose(action.side, action.qty, action.reason); + continue; + } + // PLACE_ENTRY / PLACE_EXIT:每 tick 最多 1 单 + if (limitPlaced) continue; + if (!this.canPlaceLimitNow()) continue; + const placed = await this.placeGridOrder(action); + if (placed) limitPlaced = true; + } } - private getReferencePrice(): number | null { - return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); + private canPlaceLimitNow(): boolean { + if (this.pendings["LIMIT"]) return false; + const now = this.now(); + const snapshotStale = this.lastPlacementOrdersVersion === this.ordersVersion; + const inCooldown = now - this.lastLimitAttemptAt < GridEngine.LIMIT_COOLDOWN_MS; + if (snapshotStale && inCooldown) return false; + // 陈旧性守卫:下过单但订单流一直没有反映,且行情仍在推送 → 冻结新下单 + if ( + this.lastPlacementAt > 0 && + this.ordersFeedLastAt < this.lastPlacementAt && + now - this.lastPlacementAt > GridEngine.STALE_PLACEMENT_MS && + this.tickerLastAt > now - 10_000 + ) { + if (now - this.lastStalenessLogAt > 30_000) { + this.lastStalenessLogAt = now; + this.log("warn", "订单流疑似停滞(下单后长时间未反映),暂停新下单"); + } + return false; + } + return true; } - private tryLockSidesOnce(): void { - if (this.sidesLocked) return; - if (!this.feedStatus.ticker && !this.feedStatus.depth) return; - const anchor = this.chooseAnchoringPrice(); - if (!Number.isFinite(anchor) || anchor == null) return; - const price = this.clampReferencePrice(Number(anchor)); - this.buildLevelMeta(price); - this.sidesLocked = true; - this.log("info", "已根据锚定价一次性划分买卖档位"); - } + private async placeGridOrder( + action: Extract + ): Promise { + const state = this.state; + if (!state) return false; + const now = this.now(); + const isEntry = action.kind === "PLACE_ENTRY"; + const level = isEntry ? action.level : action.source; + const target = isEntry ? undefined : action.target ?? undefined; + const clientOrderId = isEntry + ? makeEntryClientOrderId(state.gridVersion, action.level, now) + : makeExitClientOrderId(state.gridVersion, Math.max(action.source, 0), Math.max(action.target ?? 0, 0), now); - private clampReferencePrice(price: number): number { - if (!this.gridLevels.length) return price; - const minLevel = this.gridLevels[0]!; - const maxLevel = this.gridLevels[this.gridLevels.length - 1]!; - return Math.min(Math.max(price, minLevel), maxLevel); - } + // write-ahead:先落 inflight 槽位再下单,消灭“交易所已接单本地未登记”的窗口 + state.inflight = { + clientOrderId, + intent: isEntry ? "ENTRY" : "EXIT", + side: action.side, + price: action.price, + qty: action.qty, + level, + gridVersion: state.gridVersion, + createdAt: now, + }; + if (target != null) state.inflight.target = target; + await this.persistNow(); - // ----------------------------------------------------------------------- - // Stop / Halt / Restart - // ----------------------------------------------------------------------- + let placed: Order | undefined; + const ordersVersionBeforePlace = this.ordersVersion; + try { + this.lastLimitAttemptAt = now; + placed = await placeOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pendings, + action.side, + action.price, + action.qty, + this.log, + isEntry ? false : this.config.useReduceOnlyForExit, + undefined, + { + priceTick: this.config.priceTick, + qtyStep: this.config.qtyStep, + skipDedupe: true, + clientOrderId, + } + ); + } catch (error) { + this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`); + } + state.inflight = null; - private shouldStop(price: number): boolean { - if (this.config.stopLossPct <= 0) return false; - const lowerTrigger = this.config.lowerPrice * (1 - this.config.stopLossPct); - const upperTrigger = this.config.upperPrice * (1 + this.config.stopLossPct); - if (price <= lowerTrigger) { - this.stopReason = `价格跌破网格下边界 ${((1 - price / this.config.lowerPrice) * 100).toFixed(2)}%`; - return true; - } - if (price >= upperTrigger) { - this.stopReason = `价格突破网格上边界 ${((price / this.config.upperPrice - 1) * 100).toFixed(2)}%`; + if (placed?.orderId != null) { + const orderId = String(placed.orderId); + const record: OrderIntentRecord = { + orderId, + clientOrderId, + intent: isEntry ? "ENTRY" : "EXIT", + side: action.side, + price: action.price, + qty: action.qty, + level, + gridVersion: state.gridVersion, + createdAt: now, + }; + if (target != null) record.target = target; + state.intents.set(orderId, record); + const levelRuntime = state.levels[level]; + if (levelRuntime) { + if (isEntry) { + levelRuntime.phase = "entry_placed"; + levelRuntime.entryOrderId = orderId; + } else { + levelRuntime.phase = "exit_placed"; + levelRuntime.exitOrderId = orderId; + } + } + this.lastPlacementAt = now; + this.lastPlacementOrdersVersion = ordersVersionBeforePlace; + await this.persistNow(); return true; } + await this.persistNow(); return false; } - private async haltGrid(_price: number): Promise { - if (!this.running) return; - const reason = this.stopReason ?? "触发网格止损"; - this.log("warn", `${reason},开始执行平仓与撤单`); + /** 市价平仓 + maxCloseSlippagePct 滑点守卫 */ + private async guardedMarketClose(side: Side, qty: number, reason: string): Promise { + const eps = qtyEpsilon(this.config); + if (qty <= eps) return true; + const mark = this.position.markPrice; + const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]); + const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]); + const closeSidePrice = side === "SELL" ? depthBid : depthAsk; + const limitPct = this.config.maxCloseSlippagePct; + if ( + mark != null && + Number.isFinite(mark) && + mark > 0 && + Number.isFinite(closeSidePrice) && + limitPct > 0 + ) { + const pctDiff = Math.abs(closeSidePrice - mark) / mark; + if (pctDiff > limitPct) { + this.log( + "warn", + `市价平仓滑点守卫触发 (${reason}): close=${closeSidePrice} mark=${mark} 偏离 ${(pctDiff * 100).toFixed(2)}% > ${(limitPct * 100).toFixed(2)}%,暂缓` + ); + return false; + } + } try { - await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); - this.log("order", "已撤销全部网格挂单"); - } catch (error) { - this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`); - } - await this.closePosition(); - this.desiredOrders = []; - this.lastUpdated = this.now(); - this.running = false; - // Reset all level states - for (const [k] of this.levelStates) { - this.levelStates.set(k, "idle"); - } - this.exitTargetBySource.clear(); - this.awaitingByLevel.clear(); - this.orderIntentById.clear(); - this.immediateCloseToPlace = []; - // Clear persisted state - if (!this.skipPersistence) { - try { await clearGridState(this.config.symbol); } catch {} - } - if (!this.config.autoRestart) { - this.stop(); - } - } - - private async closePosition(): Promise { - const qty = this.position.positionAmt; - if (!Number.isFinite(qty) || Math.abs(qty) < EPSILON) return; - const side = qty > 0 ? "SELL" : "BUY"; - const amount = Math.abs(qty); - try { - await placeMarketOrder( + await marketClose( this.exchange, this.config.symbol, this.openOrders, @@ -753,619 +837,272 @@ export class GridEngine { this.timers, this.pendings, side, - amount, + qty, this.log, - false, - undefined, + { + markPrice: mark, + expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null, + maxPct: limitPct > 0 ? limitPct : undefined, + }, { qtyStep: this.config.qtyStep } ); - this.log("order", `市价平仓 ${side} ${amount}`); + this.log("close", `市价平仓 ${side} ${qty} (${reason})`); + return true; } catch (error) { - this.log("error", `平仓失败: ${extractMessage(error)}`); + this.log("error", `市价平仓失败 (${reason}): ${extractMessage(error)}`); + return false; } finally { unlockOperating(this.locks, this.timers, this.pendings, "MARKET"); } } + // ----------------------------------------------------------------------- + // 智能移格:cancelling → closing → rebuilding,每步幂等、phase 持久化 + // ----------------------------------------------------------------------- + + private async runShiftStep(price: number): Promise { + const state = this.state; + if (!state?.shift) return; + const step = planShiftStep(state, this.logicSettings(), { + activeOrderCount: + this.activeGridLimitOrders().length + (this.findLiveExchangeStop() ? 1 : 0), + positionAmt: this.position.positionAmt, + price, + }); + if (step.kind === "CANCEL_ALL") { + try { + await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); + state.exchangeStop = null; + this.log("order", "移格: 已请求撤销全部挂单"); + } catch (err) { + this.log("error", `移格撤单失败: ${extractMessage(err)}`); + } + } else if (step.kind === "CLOSE_POSITION") { + // 平仓单已提交但仓位回报未到时不重复提交 + const awaitingFill = + this.shiftCloseAccountVersion === this.accountVersion && + this.now() - this.shiftCloseAt < 10_000; + if (!awaitingFill) { + const done = await this.guardedMarketClose(step.side, step.qty, "移格平仓"); + if (done) { + this.shiftCloseAccountVersion = this.accountVersion; + this.shiftCloseAt = this.now(); + } else { + this.log("info", "移格: 平仓被滑点守卫暂缓,下轮重试"); + } + } + } else if (step.kind === "REBUILD") { + applyRebuild(state, this.logicSettings(), step.anchor); + this.log( + "info", + `移格完成: 新锚定价 ${step.anchor},区间 [${state.lowerPrice.toFixed(4)}, ${state.upperPrice.toFixed(4)}],gridVersion=${state.gridVersion}` + ); + } + this.lastUpdated = this.now(); + await this.persistNow(); + } + + // ----------------------------------------------------------------------- + // 止损层④:交易所侧 STOP_MARKET 兜底 + // ----------------------------------------------------------------------- + + private findLiveExchangeStop(): Order | null { + const state = this.state; + if (!state?.exchangeStop) return null; + const match = this.openOrders.find( + (order) => + String(order.orderId) === state.exchangeStop!.orderId && + !FINAL_STATUSES.has(String(order.status || "").toUpperCase()) + ); + return match ?? null; + } + + private async syncExchangeStop(price: number): Promise { + const state = this.state; + if (!state) return; + if (!this.config.exchangeStopEnabled) return; + if (!(this.exchange.supportsTriggerOrders?.() ?? false)) return; + const now = this.now(); + if (now - this.lastStopSyncAt < GridEngine.STOP_SYNC_INTERVAL_MS) return; + + const desired = desiredExchangeStop(state, this.logicSettings(), this.position.positionAmt); + const existing = state.exchangeStop; + const live = this.findLiveExchangeStop(); + + if (!desired) { + if (existing) { + this.lastStopSyncAt = now; + if (live) { + try { + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId }); + this.log("order", "已撤销交易所兜底止损单(仓位归零)"); + } catch (err) { + if (!isUnknownOrderError(err)) { + this.log("error", `撤销兜底止损单失败: ${extractMessage(err)}`); + } + } + } + state.exchangeStop = null; + this.schedulePersist(); + } + return; + } + + const sideChanged = existing != null && existing.side !== desired.side; + const priceMoved = + existing != null && Math.abs(existing.stopPrice - desired.stopPrice) > this.config.priceTick; + const liveMissing = + existing != null && live == null && now - this.stopPlacedAt > 15_000; + if (existing && live && !sideChanged && !priceMoved) return; + if (existing && !live && !liveMissing) return; + + this.lastStopSyncAt = now; + if (existing && live) { + try { + await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId }); + } catch (err) { + if (!isUnknownOrderError(err)) { + this.log("error", `撤销旧兜底止损单失败: ${extractMessage(err)}`); + return; + } + } + } + state.exchangeStop = null; + + const lastPrice = Number(this.tickerSnapshot?.lastPrice); + try { + const placed = await placeStopLossOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pendings, + desired.side, + desired.stopPrice, + Math.abs(this.position.positionAmt), + Number.isFinite(lastPrice) ? lastPrice : price, + this.log, + undefined, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep } + ); + if (placed?.orderId != null) { + state.exchangeStop = { + orderId: String(placed.orderId), + side: desired.side, + stopPrice: desired.stopPrice, + }; + this.stopPlacedAt = now; + this.schedulePersist(); + } + } catch (err) { + this.log("error", `挂兜底止损单失败: ${extractMessage(err)}`); + } + } + + // ----------------------------------------------------------------------- + // 止损层①执行 / 重启 + // ----------------------------------------------------------------------- + + private async haltGrid(reason: string): Promise { + const state = this.state; + this.stopReason = reason; + this.log("warn", `${reason},开始执行撤单与平仓`); + try { + await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); + this.log("order", "已撤销全部网格挂单"); + } catch (error) { + this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`); + } + if (state) state.exchangeStop = null; + const qty = this.position.positionAmt; + if (Math.abs(qty) > EPSILON) { + const closed = await this.guardedMarketClose(qty > 0 ? "SELL" : "BUY", Math.abs(qty), reason); + if (!closed) { + // 滑点守卫暂缓:保持 running,下个 tick 重新触发层①重试 + this.log("warn", "止损平仓被滑点守卫暂缓,下轮重试"); + return; + } + } + this.running = false; + this.lastUpdated = this.now(); + if (state) { + for (const level of state.levels) { + level.phase = "idle"; + level.holdQty = 0; + delete level.entryOrderId; + delete level.exitOrderId; + } + state.intents.clear(); + state.awaiting.clear(); + state.inflight = null; + state.shift = null; + state.prevActiveIds = new Set(); + state.seenOrderIds = new Set(); + state.uncoveredSince = null; + state.shiftCandidateSince = null; + } + this.desiredOrders = []; + this.uncoveredQty = 0; + if (!this.skipPersistence) { + try { + await clearGridState(this.config.symbol); + } catch { + // ignore + } + } + if (!this.config.autoRestart) { + this.stop(); + } + } + private async tryRestart(): Promise { if (!this.config.autoRestart || !this.configValid) return; if (!this.isReady()) return; if (this.config.restartTriggerPct <= 0) return; const price = this.getReferencePrice(); - if (!Number.isFinite(price) || price === null) return; - const lowerGuard = this.config.lowerPrice * (1 + this.config.restartTriggerPct); - const upperGuard = this.config.upperPrice * (1 - this.config.restartTriggerPct); + if (price == null || !Number.isFinite(price)) return; + const lower = this.state?.lowerPrice ?? this.config.lowerPrice; + const upper = this.state?.upperPrice ?? this.config.upperPrice; + const lowerGuard = lower * (1 + this.config.restartTriggerPct); + const upperGuard = upper * (1 - this.config.restartTriggerPct); if (price < lowerGuard || price > upperGuard) return; - this.log("info", "价格重新回到网格区间,恢复网格运行"); + const nextVersion = (this.state?.gridVersion ?? 0) + 1; + const settings = { ...this.logicSettings(), lowerPrice: lower, upperPrice: upper }; + this.state = createInitialState(settings, price, nextVersion); this.running = true; this.stopReason = null; - this.sidesLocked = false; - this.tryLockSidesOnce(); + this.initDone = true; + this.log("info", `价格重新回到网格区间,恢复网格运行 (gridVersion=${nextVersion})`); + await this.persistNow(); this.start(); } // ----------------------------------------------------------------------- - // Core grid sync logic + // Persistence // ----------------------------------------------------------------------- - private async syncGridSimple(price: number): Promise { - // Wait for recovery to complete - if (!this.recoveryDone) { - this.log("info", "恢复未完成,等待后再部网"); - this.lastUpdated = this.now(); - return; - } - - // --- 0) Exit-first: if position exists, ensure at least one EXIT order --- - const hasNetLong = this.position.positionAmt > EPSILON; - const hasNetShort = this.position.positionAmt < -EPSILON; - - if (hasNetLong || hasNetShort) { - const needExitSide: "BUY" | "SELL" = hasNetLong ? "SELL" : "BUY"; - if (!this.hasActiveExit(needExitSide)) { - await this.ensureExitForPosition(); - this.lastUpdated = this.now(); - this.prevActiveIds = new Set(this.openOrders.filter(o => this.isActiveLimitOrder(o)).map(o => String(o.orderId))); - return; - } - } - - // --- 1) Classify disappeared orders --- - const activeOrders = this.openOrders.filter(o => this.isActiveLimitOrder(o)); - const allOrdersById = new Map(); - for (const o of this.openOrders) { - if (o.symbol !== this.config.symbol) continue; - allOrdersById.set(String(o.orderId), o); - } - - const activeKeyCounts = new Map(); - const currIds = new Set(); - for (const o of activeOrders) { - const id = String(o.orderId); - currIds.add(id); - const meta = this.orderIntentById.get(id); - const priceStr = this.normalizePrice(o.price); - if (meta) { - const k = this.getOrderKey(o.side, priceStr, meta.intent); - activeKeyCounts.set(k, (activeKeyCounts.get(k) ?? 0) + 1); - } else { - // Unknown order (not placed by this engine) — count conservatively - const kEntry = this.getOrderKey(o.side, priceStr, "ENTRY"); - const kExit = this.getOrderKey(o.side, priceStr, "EXIT"); - activeKeyCounts.set(kEntry, (activeKeyCounts.get(kEntry) ?? 0) + 1); - activeKeyCounts.set(kExit, (activeKeyCounts.get(kExit) ?? 0) + 1); - } - } - - // Clear suppression for keys that are now visible - for (const [k, cnt] of activeKeyCounts.entries()) { - if ((cnt ?? 0) > 0) this.pendingKeyUntil.delete(k); - } - - // Detect disappeared orders - const disappeared: string[] = []; - for (const id of this.prevActiveIds) { - if (!currIds.has(id)) disappeared.push(id); - } - - let stateChanged = false; - - for (const id of disappeared) { - const meta = this.orderIntentById.get(id); - if (!meta) continue; - - let classified: "filled" | "canceled" | "unknown" = "unknown"; - const rec = allOrdersById.get(id); - if (rec) { - const status = String(rec.status || "").toUpperCase(); - const executed = Number(rec.executedQty || 0); - if (status === "FILLED" || executed > EPSILON) { - classified = "filled"; - } else if (["CANCELED", "CANCELLED", "EXPIRED", "REJECTED"].includes(status)) { - classified = "canceled"; - } - } - - if (classified === "unknown") { - const level = meta.intent === "EXIT" - ? (meta.sourceLevel ?? meta.level) - : meta.level; - this.awaitingByLevel.set(level, { - accountVerAtStart: this.accountVersion, - absAtStart: this.lastAbsPositionAmt, - ts: this.now(), - }); - this.orderIntentById.delete(id); - continue; - } - - if (classified === "filled") { - stateChanged = true; - if (meta.intent === "ENTRY") { - // ENTRY filled → mark level as "filled", queue EXIT - this.levelStates.set(meta.level, "filled"); - const target = this.levelMeta[meta.level]?.closeTarget; - if (target != null) { - this.exitTargetBySource.set(meta.level, target); - const exitSide: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY"; - const priceStr = this.formatPrice(this.gridLevels[target]!); - const exitKey = this.getOrderKey(exitSide, priceStr, "EXIT"); - const count = activeKeyCounts.get(exitKey) ?? 0; - if (count < 1) { - this.immediateCloseToPlace.push({ - sourceLevel: meta.level, - targetLevel: target, - side: exitSide, - price: priceStr, - }); - } - } - this.log("order", `ENTRY 成交: ${meta.side} @ ${meta.price} (等级 ${meta.level})`); - } else { - // EXIT filled → release source level back to idle - const src = meta.sourceLevel ?? meta.level; - this.levelStates.set(src, "idle"); - this.exitTargetBySource.delete(src); - this.log("order", `EXIT 成交: ${meta.side} @ ${meta.price} (释放等级 ${src})`); - } - const filledKey = this.getOrderKey(meta.side, meta.price, meta.intent); - this.pendingKeyUntil.delete(filledKey); - } else if (classified === "canceled") { - stateChanged = true; - if (meta.intent === "EXIT") { - // EXIT canceled → revert source back to "filled" so a new EXIT can be placed - const src = meta.sourceLevel ?? meta.level; - this.levelStates.set(src, "filled"); - this.exitTargetBySource.delete(src); - } - // ENTRY canceled → level stays idle (no change needed) - const canceledKey = this.getOrderKey(meta.side, meta.price, meta.intent); - this.pendingKeyUntil.delete(canceledKey); - } - this.orderIntentById.delete(id); - } - - this.prevActiveIds = currIds; - - // --- 2) Resolve deferred unknown classifications --- - if (this.awaitingByLevel.size) { - for (const [level, info] of Array.from(this.awaitingByLevel.entries())) { - if (this.now() - info.ts > 8000) { - this.awaitingByLevel.delete(level); - continue; - } - if (this.accountVersion <= info.accountVerAtStart) continue; - const absNow = Math.abs(this.position.positionAmt); - if (absNow > info.absAtStart + EPSILON) { - // ENTRY filled - this.levelStates.set(level, "filled"); - const target = this.levelMeta[level]?.closeTarget; - if (target != null) this.exitTargetBySource.set(level, target); - stateChanged = true; - this.awaitingByLevel.delete(level); - continue; - } - if (absNow + EPSILON < info.absAtStart) { - // EXIT filled - this.levelStates.set(level, "idle"); - this.exitTargetBySource.delete(level); - stateChanged = true; - this.awaitingByLevel.delete(level); - continue; - } - // No change after new account snapshot → treat as canceled - this.awaitingByLevel.delete(level); - } - } - - // --- 3) Build desired orders --- - const desired: DesiredGridOrder[] = []; - const desiredKeySet = new Set(); - const plannedKeyCounts = new Map(activeKeyCounts); - const halfTick = this.config.priceTick / 2; - - // 3a) Immediate close (EXIT) orders from fresh fills - if (this.immediateCloseToPlace.length) { - for (const item of this.immediateCloseToPlace) { - const key = this.getOrderKey(item.side, item.price, "EXIT"); - const until = this.pendingKeyUntil.get(key); - if (until && until > this.now()) continue; - const count = plannedKeyCounts.get(key) ?? 0; - if (count < 1 && !desiredKeySet.has(key)) { - desired.push({ - level: item.targetLevel, - side: item.side, - price: item.price, - amount: this.config.orderSize, - intent: "EXIT", - }); - desiredKeySet.add(key); - plannedKeyCounts.set(key, count + 1); - } - } - this.immediateCloseToPlace = []; - } - - // 3b) EXIT orders for all filled/exit_placed levels that don't already have an active EXIT - for (const [level, state] of this.levelStates) { - if (state !== "filled" && state !== "exit_placed") continue; - const target = this.exitTargetBySource.get(level); - if (target == null) continue; - const meta = this.levelMeta[level]; - if (!meta) continue; - const exitSide: "BUY" | "SELL" = meta.side === "BUY" ? "SELL" : "BUY"; - const priceStr = this.formatPrice(this.gridLevels[target]!); - const closeKey = this.getOrderKey(exitSide, priceStr, "EXIT"); - const until = this.pendingKeyUntil.get(closeKey); - if (until && until > this.now()) continue; - if ((plannedKeyCounts.get(closeKey) ?? 0) < 1 && !desiredKeySet.has(closeKey)) { - desired.push({ - level: target, - side: exitSide, - price: priceStr, - amount: this.config.orderSize, - intent: "EXIT", - }); - desiredKeySet.add(closeKey); - plannedKeyCounts.set(closeKey, (plannedKeyCounts.get(closeKey) ?? 0) + 1); - } - } - - // 3c) ENTRY BUY orders below price - for (const level of this.buyLevelIndices) { - // During exit-first phase, skip ENTRY - if (hasNetLong || hasNetShort) continue; - const levelState = this.levelStates.get(level) ?? "idle"; - if (levelState !== "idle") continue; // Level already filled — no re-entry until EXIT fills - const levelPrice = this.gridLevels[level]!; - if (levelPrice >= price - halfTick) continue; - if (this.awaitingByLevel.has(level)) continue; - const priceStr = this.formatPrice(levelPrice); - const key = this.getOrderKey("BUY", priceStr, "ENTRY"); - // Check for intent conflict with EXIT at same price - const exitKeySame = this.getOrderKey("BUY", priceStr, "EXIT"); - if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) continue; - const until = this.pendingKeyUntil.get(key); - if (until && until > this.now()) continue; - if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue; - if (!desiredKeySet.has(key)) { - desired.push({ level, side: "BUY", price: priceStr, amount: this.config.orderSize, intent: "ENTRY" }); - desiredKeySet.add(key); - plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); - } - } - - // 3d) ENTRY SELL orders above price - for (const level of this.sellLevelIndices) { - if (hasNetLong || hasNetShort) continue; - const levelState = this.levelStates.get(level) ?? "idle"; - if (levelState !== "idle") continue; - const levelPrice = this.gridLevels[level]!; - if (levelPrice <= price + halfTick) continue; - if (this.awaitingByLevel.has(level)) continue; - const priceStr = this.formatPrice(levelPrice); - const key = this.getOrderKey("SELL", priceStr, "ENTRY"); - const exitKeySame = this.getOrderKey("SELL", priceStr, "EXIT"); - if ((plannedKeyCounts.get(exitKeySame) ?? 0) >= 1 || desiredKeySet.has(exitKeySame)) continue; - const until = this.pendingKeyUntil.get(key); - if (until && until > this.now()) continue; - if ((plannedKeyCounts.get(key) ?? 0) >= 1) continue; - if (!desiredKeySet.has(key)) { - desired.push({ level, side: "SELL", price: priceStr, amount: this.config.orderSize, intent: "ENTRY" }); - desiredKeySet.add(key); - plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); - } - } - - // --- 4) Place desired orders (rate-limited) --- - this.desiredOrders = desired; - let newOrdersPlaced = 0; - const MAX_NEW_ORDERS_PER_TICK = 1; - - for (const d of desired) { - if (newOrdersPlaced >= MAX_NEW_ORDERS_PER_TICK) break; - if (this.pendings["LIMIT"]) break; - - const nowTs = this.now(); - const needSnapshotUpdated = this.lastPlacementOrdersVersion === this.ordersVersion; - const inCooldown = nowTs - this.lastLimitAttemptAt < GridEngine.LIMIT_COOLDOWN_MS; - if (needSnapshotUpdated && inCooldown) break; - - const intent = d.intent; - - // Cap quantities - if (intent === "EXIT") { - const capped = this.capExitQty(d.amount, d.side); - if (capped <= EPSILON) continue; - d.amount = capped; - } else { - const capped = this.capEntryQty(d.amount, d.side); - if (capped <= EPSILON) continue; - d.amount = capped; - } - - const key = this.getOrderKey(d.side, d.price, intent); - // Dedupe: skip if any active LIMIT exists with same side+price - const hasSameSidePrice = this.openOrders.some( - o => this.isActiveLimitOrder(o) && o.side === d.side && this.normalizePrice(o.price) === d.price - ); - if (hasSameSidePrice || (activeKeyCounts.get(key) ?? 0) >= 1) continue; - - try { - this.lastLimitAttemptAt = nowTs; - - // Generate clientOrderId for recovery - const clientOrderId = intent === "ENTRY" - ? makeClientOrderId("ENTRY", d.level) - : makeClientOrderId("EXIT", d.level, this.findSourceForExitTarget(d.level, d.side)); - - // Do NOT use reduceOnly for EXIT — some exchanges reject it alongside open ENTRY orders - const placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - d.side, - d.price, - d.amount, - this.log, - false, // never reduceOnly - undefined, - { - priceTick: this.config.priceTick, - qtyStep: this.config.qtyStep, - skipDedupe: true, - clientOrderId, - } - ); - - if (placed) { - this.lastPlacementOrdersVersion = this.ordersVersion; - newOrdersPlaced += 1; - plannedKeyCounts.set(key, (plannedKeyCounts.get(key) ?? 0) + 1); - activeKeyCounts.set(key, (activeKeyCounts.get(key) ?? 0) + 1); - this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); - - if (placed.orderId != null) { - const record: typeof this.orderIntentById extends Map ? V : never = { - side: d.side, - price: d.price, - level: d.level, - intent, - }; - if (intent === "EXIT") { - record.sourceLevel = this.findSourceForExitTarget(d.level, d.side); - // Mark source level as exit_placed - if (record.sourceLevel != null) { - this.levelStates.set(record.sourceLevel, "exit_placed"); - stateChanged = true; - } - } - this.orderIntentById.set(String(placed.orderId), record); - } - } - if (!this.pendingKeyUntil.has(key)) { - this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); - } - } catch (error) { - this.log("error", `挂单失败 (${d.side} @ ${d.price}): ${extractMessage(error)}`); - } - } - - this.lastUpdated = this.now(); - this.lastAbsPositionAmt = Math.abs(this.position.positionAmt); - - // --- 5) Persist state if changed --- - if (stateChanged) { - this.schedulePersist(); - } + private schedulePersist(): void { + if (this.skipPersistence) return; + if (this.savePending) return; + this.savePending = true; + setTimeout(() => { + this.savePending = false; + void this.persistNow(); + }, 500); } - // ----------------------------------------------------------------------- - // Exit-first helper - // ----------------------------------------------------------------------- - - private hasActiveExit(side: "BUY" | "SELL"): boolean { - for (const o of this.openOrders) { - if (!this.isActiveLimitOrder(o)) continue; - if (o.side !== side) continue; - const meta = this.orderIntentById.get(String(o.orderId)); - if (meta && meta.intent === "EXIT") return true; - // Also check clientOrderId directly - const parsed = parseClientOrderId(o.clientOrderId); - if (parsed && parsed.intent === "EXIT") return true; - } - return false; - } - - private async ensureExitForPosition(): Promise { - const qty = this.position.positionAmt; - if (!Number.isFinite(qty) || Math.abs(qty) <= EPSILON) return; - const entry = this.position.entryPrice; - if (!Number.isFinite(entry)) return; - const dir: "long" | "short" = qty > 0 ? "long" : "short"; - const nearest = this.findNearestProfitableCloseLevel(dir, Number(entry)); - if (nearest == null) return; - const exitSide: "BUY" | "SELL" = qty > 0 ? "SELL" : "BUY"; - const priceStr = this.formatPrice(this.gridLevels[nearest]!); - const key = this.getOrderKey(exitSide, priceStr, "EXIT"); - const until = this.pendingKeyUntil.get(key); - if (until && until > this.now()) return; - - // Find or create source level - const source = this.findSourceForInitialPosition(exitSide); - const clientOrderId = makeClientOrderId("EXIT", nearest, source); - + private async persistNow(): Promise { + if (this.skipPersistence) return; + const state = this.state; + if (!state) return; try { - const placed = await placeOrder( - this.exchange, - this.config.symbol, - this.openOrders, - this.locks, - this.timers, - this.pendings, - exitSide, - priceStr, - Math.abs(qty), - this.log, - false, // no reduceOnly - undefined, - { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true, clientOrderId } - ); - this.pendingKeyUntil.set(key, this.now() + GridEngine.PENDING_TTL_MS); - if (placed?.orderId != null) { - this.levelStates.set(source, "exit_placed"); - this.exitTargetBySource.set(source, nearest); - this.orderIntentById.set(String(placed.orderId), { - side: exitSide, - price: priceStr, - level: nearest, - intent: "EXIT", - sourceLevel: source, - }); - this.log("order", `兜底:为已有仓位挂平仓单 ${exitSide} @ ${priceStr}`); - this.schedulePersist(); - } + await saveGridState(toStored(state, this.stateMeta(), this.now())); } catch (err) { - this.log("error", `兜底平仓单下单失败: ${extractMessage(err)}`); + this.log("error", `保存网格状态失败: ${extractMessage(err)}`); } } - // ----------------------------------------------------------------------- - // Quantity capping - // ----------------------------------------------------------------------- - - private capExitQty(desiredQty: number, side: "BUY" | "SELL"): number { - const absPos = Math.abs(this.position.positionAmt); - if (absPos <= EPSILON) return 0; - let pendingExitQty = 0; - for (const o of this.openOrders) { - if (!this.isActiveLimitOrder(o)) continue; - if (o.side !== side) continue; - const meta = this.orderIntentById.get(String(o.orderId)); - if (!meta || meta.intent !== "EXIT") { - // Also check clientOrderId - const parsed = parseClientOrderId(o.clientOrderId); - if (!parsed || parsed.intent !== "EXIT") continue; - } - const orig = Number(o.origQty || 0); - const exec = Number(o.executedQty || 0); - pendingExitQty += Math.max(orig - exec, 0); - } - const remain = Math.max(absPos - pendingExitQty, 0); - return Math.min(desiredQty, remain); - } - - private capEntryQty(desiredQty: number, _side: "BUY" | "SELL"): number { - const absPos = Math.abs(this.position.positionAmt); - const remain = Math.max(this.config.maxPositionSize - absPos, 0); - return Math.min(desiredQty, remain); - } - - // ----------------------------------------------------------------------- - // Level lookup helpers - // ----------------------------------------------------------------------- - - /** Find the source level for a given EXIT target level */ - private findSourceForExitTarget(targetLevel: number, side: "BUY" | "SELL"): number { - // side is the EXIT order side - if (side === "SELL") { - // Closing long: source is a BUY level that maps to targetLevel - for (const [src, tgt] of this.exitTargetBySource) { - if (tgt === targetLevel && this.levelMeta[src]?.side === "BUY") return src; - } - // Fallback: check levelMeta - for (const meta of this.levelMeta) { - if (meta.side === "BUY" && meta.closeTarget === targetLevel) { - const state = this.levelStates.get(meta.index); - if (state === "filled" || state === "exit_placed") return meta.index; - } - } - } else { - for (const [src, tgt] of this.exitTargetBySource) { - if (tgt === targetLevel && this.levelMeta[src]?.side === "SELL") return src; - } - for (const meta of this.levelMeta) { - if (meta.side === "SELL" && meta.closeTarget === targetLevel) { - const state = this.levelStates.get(meta.index); - if (state === "filled" || state === "exit_placed") return meta.index; - } - } - } - return targetLevel; - } - - private findNearestProfitableCloseLevel(direction: "long" | "short", entryPrice: number): number | null { - if (!this.levelMeta.length) return null; - if (direction === "long") { - for (const idx of this.sellLevelIndices) { - if (this.gridLevels[idx]! > entryPrice + this.config.priceTick / 2) return idx; - } - return this.sellLevelIndices.length ? this.sellLevelIndices[0]! : null; - } - for (const idx of this.buyLevelIndices.slice().reverse()) { - if (this.gridLevels[idx]! < entryPrice - this.config.priceTick / 2) return idx; - } - return this.buyLevelIndices.length ? this.buyLevelIndices[this.buyLevelIndices.length - 1]! : null; - } - - private findSourceForInitialPosition(closeSide: "BUY" | "SELL"): number { - const price = this.getReferencePrice(); - if (!Number.isFinite(price)) return 0; - const p = Number(price); - if (closeSide === "SELL") { - let best = 0; - let bestDiff = Number.POSITIVE_INFINITY; - for (const idx of this.buyLevelIndices) { - const lv = this.gridLevels[idx]!; - const diff = p - lv; - if (diff >= 0 && diff < bestDiff) { - bestDiff = diff; - best = idx; - } - } - return best; - } - let best = 0; - let bestDiff = Number.POSITIVE_INFINITY; - for (const idx of this.sellLevelIndices) { - const lv = this.gridLevels[idx]!; - const diff = lv - p; - if (diff >= 0 && diff < bestDiff) { - bestDiff = diff; - best = idx; - } - } - return best; - } - - // ----------------------------------------------------------------------- - // Grid level computation - // ----------------------------------------------------------------------- - - private computeGridLevels(): number[] { - if (!this.configValid) return []; - const { lowerPrice, upperPrice, gridLevels } = this.config; - if (gridLevels <= 1) return [Number(lowerPrice.toFixed(this.priceDecimals)), Number(upperPrice.toFixed(this.priceDecimals))]; - if (this.config.gridMode === "geometric") { - const ratio = Math.pow(upperPrice / lowerPrice, 1 / (gridLevels - 1)); - const levels: number[] = []; - for (let i = 0; i < gridLevels; i += 1) { - const price = lowerPrice * Math.pow(ratio, i); - levels.push(Number(price.toFixed(this.priceDecimals))); - } - if (levels.length) { - levels[0] = Number(lowerPrice.toFixed(this.priceDecimals)); - levels[levels.length - 1] = Number(upperPrice.toFixed(this.priceDecimals)); - } - return levels; - } - this.log("error", `不支持的网格模式: ${String(this.config.gridMode)}`); - return []; - } - // ----------------------------------------------------------------------- // Snapshot // ----------------------------------------------------------------------- @@ -1374,46 +1111,43 @@ export class GridEngine { const reference = this.getReferencePrice(); const tickerLast = Number(this.tickerSnapshot?.lastPrice); const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference; - const midPrice = reference; - const desiredKeys = new Set( - this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.intent)) - ); - const openOrderKeys = new Set( - this.openOrders - .filter((order) => this.isActiveLimitOrder(order)) - .map((order) => { - const id = String(order.orderId); - const meta = this.orderIntentById.get(id); - const intent: "ENTRY" | "EXIT" = meta?.intent ?? "ENTRY"; - return this.getOrderKey(order.side, this.normalizePrice(order.price), intent); - }) - ); + const state = this.state; + const activeIds = new Set(this.activeGridLimitOrders().map((order) => String(order.orderId))); - const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { - const desired = this.desiredOrders.find((order) => order.level === level); - const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL"; - const side = desired?.side ?? defaultSide; - const key = desired ? this.getOrderKey(desired.side, desired.price, desired.intent) : null; - const hasOrder = key ? openOrderKeys.has(key) : false; - const active = Boolean(desired && key && desiredKeys.has(key)); - const state = this.levelStates.get(level) ?? "idle"; - return { level, price, side, active, hasOrder, state }; - }); + const gridLines: GridLineSnapshot[] = (state?.levels ?? []).map((level) => ({ + level: level.index, + price: level.price, + side: level.entrySide ?? "-", + role: + level.entrySide === "BUY" ? "entry-buy" : level.entrySide === "SELL" ? "entry-sell" : "none", + state: level.phase, + hasOrder: + (level.entryOrderId != null && activeIds.has(level.entryOrderId)) || + (level.exitOrderId != null && activeIds.has(level.exitOrderId)), + holdQty: level.holdQty, + })); return { - ready: this.isReady() && this.running, + ready: this.isReady() && this.running && this.state != null, symbol: this.config.symbol, - lowerPrice: this.config.lowerPrice, - upperPrice: this.config.upperPrice, + lowerPrice: state?.lowerPrice ?? this.config.lowerPrice, + upperPrice: state?.upperPrice ?? this.config.upperPrice, + gridVersion: state?.gridVersion ?? 0, + anchorPrice: state?.anchorPrice ?? null, + shiftPhase: state?.shift?.phase ?? null, lastPrice, - midPrice, + midPrice: reference, gridLines, desiredOrders: this.desiredOrders.slice(), - openOrders: this.openOrders.filter((order) => this.isActiveLimitOrder(order)), + openOrders: this.activeGridLimitOrders(), position: this.position, running: this.running, stopReason: this.running ? null : this.stopReason, direction: this.config.direction, + stopProtection: { + uncoveredQty: this.uncoveredQty, + exchangeStop: state?.exchangeStop ?? null, + }, tradeLog: this.tradeLog.all().slice(), feedStatus: { ...this.feedStatus }, lastUpdated: this.lastUpdated, @@ -1423,260 +1157,4 @@ export class GridEngine { private emitUpdate(): void { this.events.emit("update", this.buildSnapshot()); } - - // ----------------------------------------------------------------------- - // State persistence (debounced) - // ----------------------------------------------------------------------- - - private schedulePersist(): void { - if (this.skipPersistence) return; - if (this.savePending) return; - this.savePending = true; - setTimeout(() => { - this.savePending = false; - void this.persistState(); - }, 500); - } - - private async persistState(): Promise { - const levels: Record = {}; - for (const [idx, state] of this.levelStates) { - if (state === "idle") continue; - levels[String(idx)] = { - state, - sourceLevel: idx, - targetLevel: this.exitTargetBySource.get(idx) ?? null, - }; - } - const snapshot: StoredGridState = { - symbol: this.config.symbol, - lowerPrice: this.config.lowerPrice, - upperPrice: this.config.upperPrice, - gridLevels: this.config.gridLevels, - orderSize: this.config.orderSize, - maxPositionSize: this.config.maxPositionSize, - direction: this.config.direction, - levels, - updatedAt: this.now(), - }; - try { - await saveGridState(snapshot); - } catch (err) { - this.log("error", `保存网格状态失败: ${extractMessage(err)}`); - } - } - - // ----------------------------------------------------------------------- - // Utility methods - // ----------------------------------------------------------------------- - - private getOrderKey(side: "BUY" | "SELL", price: string, intent: "ENTRY" | "EXIT" = "ENTRY"): string { - return `${side}:${price}:${intent}`; - } - - private isActiveLimitOrder(o: Order): boolean { - if (o.symbol !== this.config.symbol) return false; - if (o.type !== "LIMIT") return false; - const s = String(o.status || "").toUpperCase(); - return !["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"].includes(s); - } - - private normalizePrice(price: string | number): string { - const numeric = Number(price); - if (!Number.isFinite(numeric)) return "0"; - return numeric.toFixed(this.priceDecimals); - } - - private formatPrice(price: number): string { - if (!Number.isFinite(price)) return "0"; - return Number(price).toFixed(this.priceDecimals); - } - - private buildLevelMeta(referencePrice?: number | null): void { - this.levelMeta.length = 0; - this.buyLevelIndices.length = 0; - this.sellLevelIndices.length = 0; - if (!this.gridLevels.length) return; - const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2); - const hasReference = Number.isFinite(referencePrice ?? NaN); - const pivotPrice = hasReference ? this.clampReferencePrice(Number(referencePrice)) : null; - for (let i = 0; i < this.gridLevels.length; i += 1) { - let side: "BUY" | "SELL"; - if (pivotPrice != null) { - side = this.gridLevels[i]! <= pivotPrice + EPSILON ? "BUY" : "SELL"; - } else { - side = i <= pivotIndex ? "BUY" : "SELL"; - } - const meta: LevelMeta = { - index: i, - price: this.gridLevels[i]!, - side, - closeTarget: null, - closeSources: [], - }; - this.levelMeta.push(meta); - if (side === "BUY") this.buyLevelIndices.push(i); - else this.sellLevelIndices.push(i); - } - for (const meta of this.levelMeta) { - if (meta.side === "BUY") { - for (let j = meta.index + 1; j < this.levelMeta.length; j += 1) { - if (this.levelMeta[j]!.side === "SELL") { - meta.closeTarget = this.levelMeta[j]!.index; - this.levelMeta[j]!.closeSources.push(meta.index); - break; - } - } - } else { - for (let j = meta.index - 1; j >= 0; j -= 1) { - if (this.levelMeta[j]!.side === "BUY") { - meta.closeTarget = this.levelMeta[j]!.index; - this.levelMeta[j]!.closeSources.push(meta.index); - break; - } - } - } - } - } - - private chooseAnchoringPrice(): number | null { - const reference = this.getReferencePrice(); - if (!Number.isFinite(reference) || reference == null) return null; - const ref = Number(reference); - const qty = this.position.positionAmt; - const entry = this.position.entryPrice; - const hasEntry = Number.isFinite(entry) && Math.abs(entry) > EPSILON; - if (!hasEntry || Math.abs(qty) <= EPSILON) return ref; - if (qty > 0 && ref < Number(entry) - EPSILON) return Number(entry); - if (qty < 0 && ref > Number(entry) + EPSILON) return Number(entry); - return ref; - } - - // ----------------------------------------------------------------------- - // Legacy helpers (retained for test backward compat) - // ----------------------------------------------------------------------- - - private computeDesiredOrders(price: number): DesiredGridOrder[] { - if (!Number.isFinite(price)) return []; - const desired: DesiredGridOrder[] = []; - const halfTick = this.config.priceTick / 2; - let remainingLong = Math.max(this.config.maxPositionSize - this.sumExposure(this.longExposure), 0); - let remainingShort = Math.max(this.config.maxPositionSize - this.sumExposure(this.shortExposure), 0); - - for (const level of this.buyLevelIndices.slice().reverse()) { - if (this.config.direction === "short") break; - if (this.longExposure.has(level)) continue; - const levelPrice = this.gridLevels[level]!; - if (levelPrice >= price - halfTick) continue; - if (remainingLong <= EPSILON) continue; - const amount = Math.min(this.config.orderSize, remainingLong); - if (amount <= EPSILON) continue; - desired.push({ - level, - side: "BUY", - price: this.formatPrice(levelPrice), - amount, - intent: "ENTRY", - reduceOnly: false, - }); - remainingLong -= amount; - } - - for (const level of this.sellLevelIndices) { - if (this.config.direction === "long") break; - if (this.shortExposure.has(level)) continue; - const levelPrice = this.gridLevels[level]!; - if (levelPrice <= price + halfTick) continue; - if (remainingShort <= EPSILON) continue; - const amount = Math.min(this.config.orderSize, remainingShort); - if (amount <= EPSILON) continue; - desired.push({ - level, - side: "SELL", - price: this.formatPrice(levelPrice), - amount, - intent: "ENTRY", - reduceOnly: false, - }); - remainingShort -= amount; - } - - const longByTarget = new Map(); - for (const [sourceLevel, qty] of this.longExposure.entries()) { - const target = this.levelMeta[sourceLevel]?.closeTarget; - if (target == null) continue; - longByTarget.set(target, (longByTarget.get(target) ?? 0) + qty); - } - for (const target of Array.from(longByTarget.keys()).sort((a, b) => a - b)) { - desired.push({ - level: target, - side: "SELL", - price: this.formatPrice(this.gridLevels[target]!), - amount: longByTarget.get(target)!, - intent: "EXIT", - reduceOnly: true, - }); - } - - const shortByTarget = new Map(); - for (const [sourceLevel, qty] of this.shortExposure.entries()) { - const target = this.levelMeta[sourceLevel]?.closeTarget; - if (target == null) continue; - shortByTarget.set(target, (shortByTarget.get(target) ?? 0) + qty); - } - for (const target of Array.from(shortByTarget.keys()).sort((a, b) => a - b)) { - desired.push({ - level: target, - side: "BUY", - price: this.formatPrice(this.gridLevels[target]!), - amount: shortByTarget.get(target)!, - intent: "EXIT", - reduceOnly: true, - }); - } - - return desired; - } - - private async syncGrid(price: number): Promise { - this.syncLegacyExposureFromPosition(); - this.desiredOrders = this.computeDesiredOrders(price); - this.lastUpdated = this.now(); - } - - private syncLegacyExposureFromPosition(): void { - const qty = this.position.positionAmt; - if (!Number.isFinite(qty) || Math.abs(qty) <= EPSILON) { - this.longExposure.clear(); - this.shortExposure.clear(); - return; - } - if (qty > 0) { - this.shortExposure.clear(); - this.longExposure.clear(); - let remaining = Math.abs(qty); - for (const level of this.buyLevelIndices.slice().reverse()) { - if (remaining <= EPSILON) break; - const amount = Math.min(this.config.orderSize, remaining); - this.longExposure.set(level, amount); - remaining -= amount; - } - return; - } - this.longExposure.clear(); - this.shortExposure.clear(); - let remaining = Math.abs(qty); - for (const level of this.sellLevelIndices) { - if (remaining <= EPSILON) break; - const amount = Math.min(this.config.orderSize, remaining); - this.shortExposure.set(level, amount); - remaining -= amount; - } - } - - private sumExposure(map: Map): number { - let total = 0; - for (const qty of map.values()) total += qty; - return total; - } } diff --git a/src/strategy/grid-logic.test.ts b/src/strategy/grid-logic.test.ts new file mode 100644 index 0000000..af154e1 --- /dev/null +++ b/src/strategy/grid-logic.test.ts @@ -0,0 +1,1036 @@ +import { describe, expect, it } from "vitest"; +import { + ORPHAN_LEVEL, + applyRebuild, + assignRoles, + auditExitCoverage, + beginShift, + capEntryQty, + checkPriceStop, + classifyDisappearance, + computeLevelPrices, + createInitialState, + desiredExchangeStop, + fromStored, + isCompatibleStoredState, + makeEntryClientOrderId, + makeExitClientOrderId, + parseClientOrderId, + planOrders, + planShiftStep, + planTick, + processOrderSnapshot, + reconcile, + resolveAwaiting, + shouldShift, + toStored, + type GridLogicSettings, + type GridLogicState, + type GridTickInput, + type OrderIntentRecord, + type OrderView, + type StateMeta, +} from "./grid-logic"; + +const settings: GridLogicSettings = { + direction: "neutral", + lowerPrice: 100, + upperPrice: 200, + gridLevels: 5, + orderSize: 0.1, + maxPositionSize: 0.4, + priceTick: 0.1, + qtyStep: 0.001, + stopLossPct: 0.01, + uncoveredGraceMs: 5000, + shiftEnabled: false, + shiftTriggerPct: 0.05, + shiftRangePct: 0.05, + shiftConfirmMs: 3000, +}; + +const meta: StateMeta = { + symbol: "BTCUSDT", + exchangeId: "aster", + direction: "neutral", + orderSize: 0.1, + maxPositionSize: 0.4, + gridLevels: 5, + gridMode: "geometric", +}; + +function makeInput(overrides: Partial = {}): GridTickInput { + return { + now: 10_000, + price: 141.4, + positionAmt: 0, + entryPrice: 0, + accountVersion: 1, + activeOrders: [], + allOrders: [], + ...overrides, + }; +} + +function makeOrder(overrides: Partial = {}): OrderView { + return { + orderId: "o-1", + side: "BUY", + price: 118.9, + status: "NEW", + executedQty: 0, + origQty: 0.1, + type: "LIMIT", + ...overrides, + }; +} + +function registerIntent(state: GridLogicState, intent: OrderIntentRecord): void { + state.intents.set(intent.orderId, intent); + const level = state.levels[intent.level]; + if (!level) return; + if (intent.intent === "ENTRY") { + level.phase = "entry_placed"; + level.entryOrderId = intent.orderId; + } else { + level.phase = "exit_placed"; + level.exitOrderId = intent.orderId; + if (level.holdQty <= 0) level.holdQty = intent.qty; + } +} + +function entryIntent(state: GridLogicState, level: number, orderId: string): OrderIntentRecord { + const l = state.levels[level]!; + return { + orderId, + intent: "ENTRY", + side: l.entrySide!, + price: l.price.toFixed(1), + qty: 0.1, + level, + gridVersion: state.gridVersion, + createdAt: 0, + }; +} + +function exitIntent(state: GridLogicState, source: number, orderId: string): OrderIntentRecord { + const l = state.levels[source]!; + const target = l.exitTarget!; + return { + orderId, + intent: "EXIT", + side: l.entrySide === "BUY" ? "SELL" : "BUY", + price: state.levels[target]!.price.toFixed(1), + qty: 0.1, + level: source, + target, + gridVersion: state.gridVersion, + createdAt: 0, + }; +} + +// --------------------------------------------------------------------------- +// 网格价位与角色分配 +// --------------------------------------------------------------------------- + +describe("computeLevelPrices", () => { + it("builds geometric levels pinned at both bounds", () => { + const prices = computeLevelPrices(100, 200, 5, 0.1); + expect(prices).toHaveLength(5); + expect(prices[0]).toBe(100); + expect(prices[4]).toBe(200); + expect(prices[2]).toBeCloseTo(141.4, 1); + for (let i = 1; i < prices.length; i += 1) { + expect(prices[i]!).toBeGreaterThan(prices[i - 1]!); + } + }); + + it("returns empty for invalid params", () => { + expect(computeLevelPrices(0, 200, 5, 0.1)).toEqual([]); + expect(computeLevelPrices(200, 100, 5, 0.1)).toEqual([]); + expect(computeLevelPrices(100, 200, 1, 0.1)).toEqual([]); + }); +}); + +describe("assignRoles", () => { + const prices = computeLevelPrices(100, 200, 5, 0.1); + + it("long: all lines except top are BUY with exit at i+1", () => { + const roles = assignRoles(prices, "long", 150); + for (let i = 0; i < 4; i += 1) { + expect(roles[i]).toEqual({ entrySide: "BUY", exitTarget: i + 1 }); + } + expect(roles[4]).toEqual({ entrySide: null, exitTarget: null }); + }); + + it("short: all lines except bottom are SELL with exit at i-1", () => { + const roles = assignRoles(prices, "short", 150); + expect(roles[0]).toEqual({ entrySide: null, exitTarget: null }); + for (let i = 1; i < 5; i += 1) { + expect(roles[i]).toEqual({ entrySide: "SELL", exitTarget: i - 1 }); + } + }); + + it("neutral: BUY below anchor, SELL above", () => { + const roles = assignRoles(prices, "neutral", 141.4); + expect(roles[0]).toEqual({ entrySide: "BUY", exitTarget: 1 }); + expect(roles[1]).toEqual({ entrySide: "BUY", exitTarget: 2 }); + expect(roles[2]).toEqual({ entrySide: "BUY", exitTarget: 3 }); // 等于锚定价归买侧 + expect(roles[3]).toEqual({ entrySide: "SELL", exitTarget: 2 }); + expect(roles[4]).toEqual({ entrySide: "SELL", exitTarget: 3 }); + }); + + it("neutral: anchor above range makes the top line non-entry", () => { + const roles = assignRoles(prices, "neutral", 500); + expect(roles[4]).toEqual({ entrySide: null, exitTarget: null }); + expect(roles[3]).toEqual({ entrySide: "BUY", exitTarget: 4 }); + }); + + it("neutral: anchor below range makes the bottom line non-entry", () => { + const roles = assignRoles(prices, "neutral", 50); + expect(roles[0]).toEqual({ entrySide: null, exitTarget: null }); + expect(roles[1]).toEqual({ entrySide: "SELL", exitTarget: 0 }); + }); +}); + +// --------------------------------------------------------------------------- +// clientOrderId +// --------------------------------------------------------------------------- + +describe("clientOrderId", () => { + it("round-trips versioned ENTRY/EXIT ids", () => { + const e = makeEntryClientOrderId(3, 2, 0x1234); + expect(parseClientOrderId(e)).toEqual({ intent: "ENTRY", gridVersion: 3, level: 2 }); + const x = makeExitClientOrderId(3, 1, 2, 0x1234); + expect(parseClientOrderId(x)).toEqual({ intent: "EXIT", gridVersion: 3, level: 1, target: 2 }); + }); + + it("parses legacy unversioned ids", () => { + expect(parseClientOrderId("grid-E-2-abc")).toEqual({ intent: "ENTRY", gridVersion: null, level: 2 }); + expect(parseClientOrderId("grid-X-1-2-abc")).toEqual({ + intent: "EXIT", + gridVersion: null, + level: 1, + target: 2, + }); + }); + + it("rejects foreign ids", () => { + expect(parseClientOrderId("x-1234")).toBeNull(); + expect(parseClientOrderId("")).toBeNull(); + expect(parseClientOrderId(undefined)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 挂单规划:每线一单、模式方向 +// --------------------------------------------------------------------------- + +describe("planOrders", () => { + it("neutral mode places BUY entries below price and SELL above", () => { + const state = createInitialState(settings, 141.4); + const actions = planOrders(state, settings, makeInput({ price: 141.4 })); + const entries = actions.filter((a) => a.kind === "PLACE_ENTRY"); + for (const entry of entries) { + if (entry.kind !== "PLACE_ENTRY") continue; + if (entry.side === "BUY") expect(Number(entry.price)).toBeLessThan(141.4); + else expect(Number(entry.price)).toBeGreaterThan(141.4); + } + expect(entries.some((a) => a.kind === "PLACE_ENTRY" && a.side === "BUY")).toBe(true); + expect(entries.some((a) => a.kind === "PLACE_ENTRY" && a.side === "SELL")).toBe(true); + }); + + it("long mode never emits SELL entries", () => { + const longSettings = { ...settings, direction: "long" as const }; + const state = createInitialState(longSettings, 141.4); + const actions = planOrders(state, longSettings, makeInput({ price: 141.4 })); + expect(actions.every((a) => a.kind !== "PLACE_ENTRY" || a.side === "BUY")).toBe(true); + expect(actions.some((a) => a.kind === "PLACE_ENTRY")).toBe(true); + }); + + it("short mode never emits BUY entries", () => { + const shortSettings = { ...settings, direction: "short" as const }; + const state = createInitialState(shortSettings, 141.4); + const actions = planOrders(state, shortSettings, makeInput({ price: 141.4 })); + expect(actions.every((a) => a.kind !== "PLACE_ENTRY" || a.side === "SELL")).toBe(true); + expect(actions.some((a) => a.kind === "PLACE_ENTRY")).toBe(true); + }); + + it("skips ENTRY for levels in entry_placed/holding/exit_placed/awaiting", () => { + const state = createInitialState(settings, 141.4); + state.levels[0]!.phase = "entry_placed"; + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + state.levels[3]!.phase = "exit_placed"; + state.awaiting.set(4, { + intent: "ENTRY", + level: 4, + side: "SELL", + qty: 0.1, + posAtStart: 0, + accountVersionAtStart: 1, + ts: 0, + }); + const actions = planOrders(state, settings, makeInput({ price: 141.4, positionAmt: 0.1 })); + const entryLevels = actions + .filter((a) => a.kind === "PLACE_ENTRY") + .map((a) => (a.kind === "PLACE_ENTRY" ? a.level : -99)); + expect(entryLevels).not.toContain(0); + expect(entryLevels).not.toContain(1); + expect(entryLevels).not.toContain(3); + expect(entryLevels).not.toContain(4); + }); + + it("pairs exits with the adjacent line", () => { + const state = createInitialState(settings, 141.4); + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + const actions = planOrders(state, settings, makeInput({ price: 130, positionAmt: 0.1 })); + const exit = actions.find((a) => a.kind === "PLACE_EXIT"); + expect(exit).toBeTruthy(); + if (exit?.kind === "PLACE_EXIT") { + expect(exit.source).toBe(1); + expect(exit.target).toBe(2); + expect(exit.side).toBe("SELL"); + expect(Number(exit.price)).toBeCloseTo(state.levels[2]!.price, 6); + expect(exit.qty).toBeCloseTo(0.1, 9); + } + }); + + it("short holding exits at the adjacent lower line", () => { + const state = createInitialState(settings, 141.4); + state.levels[3]!.phase = "holding"; + state.levels[3]!.holdQty = 0.1; + const actions = planOrders(state, settings, makeInput({ price: 175, positionAmt: -0.1 })); + const exit = actions.find((a) => a.kind === "PLACE_EXIT"); + expect(exit).toBeTruthy(); + if (exit?.kind === "PLACE_EXIT") { + expect(exit.source).toBe(3); + expect(exit.target).toBe(2); + expect(exit.side).toBe("BUY"); + } + }); + + it("caps exit qty by remaining position budget", () => { + const state = createInitialState(settings, 141.4); + state.levels[0]!.phase = "holding"; + state.levels[0]!.holdQty = 0.1; + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + // 实际仓位只有 0.1:只允许一条线挂出 EXIT + const actions = planOrders(state, settings, makeInput({ price: 130, positionAmt: 0.1 })); + const exits = actions.filter((a) => a.kind === "PLACE_EXIT"); + const totalExit = exits.reduce((acc, a) => acc + (a.kind === "PLACE_EXIT" ? a.qty : 0), 0); + expect(totalExit).toBeCloseTo(0.1, 9); + }); + + it("allows a level ENTRY to coexist with an adjacent line's EXIT at the same price", () => { + const state = createInitialState(settings, 141.4); + // 线 2 持仓、EXIT 目标 3;线 3 自身的 SELL 开仓不受影响(向上穿越 = 平多 + 开空) + state.levels[2]!.phase = "exit_placed"; + state.levels[2]!.holdQty = 0.1; + registerIntent(state, exitIntent(state, 2, "x-1")); + const actions = planOrders(state, settings, makeInput({ price: 150, positionAmt: 0.1 })); + const entryAt3 = actions.find((a) => a.kind === "PLACE_ENTRY" && a.level === 3); + expect(entryAt3).toBeTruthy(); + const entryAt4 = actions.find((a) => a.kind === "PLACE_ENTRY" && a.level === 4); + expect(entryAt4).toBeTruthy(); + }); +}); + +describe("capEntryQty", () => { + it("subtracts same-direction net position and in-flight entries", () => { + const state = createInitialState(settings, 141.4); + registerIntent(state, entryIntent(state, 0, "e-0")); // BUY 在途 0.1 + // maxPositionSize 0.4,净多 0.2,在途 0.1 → 剩余 0.1 + expect(capEntryQty(state, settings, "BUY", 0.2, 0.1)).toBeCloseTo(0.1, 9); + // 再叠一个在途后剩余为 0 + registerIntent(state, entryIntent(state, 1, "e-1")); + expect(capEntryQty(state, settings, "BUY", 0.2, 0.1)).toBeCloseTo(0, 9); + }); + + it("neutral constrains each side independently", () => { + const state = createInitialState(settings, 141.4); + // 净空 0.4 打满 SELL 侧,但 BUY 侧不受影响 + expect(capEntryQty(state, settings, "SELL", -0.4, 0.1)).toBeCloseTo(0, 9); + expect(capEntryQty(state, settings, "BUY", -0.4, 0.1)).toBeCloseTo(0.1, 9); + }); + + it("counts inflight write-ahead slot", () => { + const state = createInitialState(settings, 141.4); + state.inflight = { + clientOrderId: "grid-1-E-0-a", + intent: "ENTRY", + side: "BUY", + price: "100.0", + qty: 0.35, + level: 0, + gridVersion: 1, + createdAt: 0, + }; + expect(capEntryQty(state, settings, "BUY", 0, 0.1)).toBeCloseTo(0.05, 9); + }); +}); + +// --------------------------------------------------------------------------- +// 生命周期与消失分类 +// --------------------------------------------------------------------------- + +describe("order lifecycle", () => { + it("classifies disappearance by final status", () => { + expect(classifyDisappearance(makeOrder({ status: "FILLED", executedQty: 0.1 })).cls).toBe("filled"); + expect(classifyDisappearance(makeOrder({ status: "NEW", executedQty: 0.05 })).cls).toBe("filled"); + expect(classifyDisappearance(makeOrder({ status: "CANCELED" })).cls).toBe("canceled"); + expect(classifyDisappearance(makeOrder({ status: "EXPIRED" })).cls).toBe("canceled"); + expect(classifyDisappearance(undefined).cls).toBe("unknown"); + }); + + it("walks idle → entry_placed → holding → exit_placed → idle", () => { + const state = createInitialState(settings, 141.4); + const level = 1; + // entry_placed + registerIntent(state, entryIntent(state, level, "e-1")); + expect(state.levels[level]!.phase).toBe("entry_placed"); + // 出现在活跃单里 + const active = makeOrder({ orderId: "e-1", price: state.levels[level]!.price }); + processOrderSnapshot(state, makeInput({ activeOrders: [active], allOrders: [active] })); + // 消失且 FILLED → holding + const filled = { ...active, status: "FILLED", executedQty: 0.1 }; + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [filled] })); + expect(state.levels[level]!.phase).toBe("holding"); + expect(state.levels[level]!.holdQty).toBeCloseTo(0.1, 9); + // exit_placed + registerIntent(state, exitIntent(state, level, "x-1")); + expect(state.levels[level]!.phase).toBe("exit_placed"); + const exitActive = makeOrder({ + orderId: "x-1", + side: "SELL", + price: state.levels[2]!.price, + }); + processOrderSnapshot(state, makeInput({ activeOrders: [exitActive], allOrders: [exitActive] })); + // EXIT 成交 → idle 释放 + const exitFilled = { ...exitActive, status: "FILLED", executedQty: 0.1 }; + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [exitFilled] })); + expect(state.levels[level]!.phase).toBe("idle"); + expect(state.levels[level]!.holdQty).toBe(0); + }); + + it("ENTRY canceled returns the level to idle", () => { + const state = createInitialState(settings, 141.4); + registerIntent(state, entryIntent(state, 1, "e-1")); + const active = makeOrder({ orderId: "e-1", price: state.levels[1]!.price }); + processOrderSnapshot(state, makeInput({ activeOrders: [active], allOrders: [active] })); + const canceled = { ...active, status: "CANCELED" }; + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [canceled] })); + expect(state.levels[1]!.phase).toBe("idle"); + }); + + it("EXIT canceled reverts the source level to holding", () => { + const state = createInitialState(settings, 141.4); + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + registerIntent(state, exitIntent(state, 1, "x-1")); + const active = makeOrder({ orderId: "x-1", side: "SELL", price: state.levels[2]!.price }); + processOrderSnapshot(state, makeInput({ activeOrders: [active], allOrders: [active] })); + const canceled = { ...active, status: "CANCELED" }; + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [canceled] })); + expect(state.levels[1]!.phase).toBe("holding"); + expect(state.levels[1]!.holdQty).toBeCloseTo(0.1, 9); + }); + + it("unknown disappearance defers to awaiting and blocks re-entry", () => { + const state = createInitialState(settings, 141.4); + registerIntent(state, entryIntent(state, 1, "e-1")); + const active = makeOrder({ orderId: "e-1", price: state.levels[1]!.price }); + processOrderSnapshot(state, makeInput({ activeOrders: [active], allOrders: [active] })); + // 消失且无记录 → awaiting + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [] })); + expect(state.awaiting.has(1)).toBe(true); + const actions = planOrders(state, settings, makeInput({ price: 141.4 })); + expect(actions.some((a) => a.kind === "PLACE_ENTRY" && a.level === 1)).toBe(false); + }); + + it("does not re-place ENTRY across repeated price crossings while holding", () => { + const state = createInitialState(settings, 141.4); + const level = 1; + registerIntent(state, entryIntent(state, level, "e-1")); + const active = makeOrder({ orderId: "e-1", price: state.levels[level]!.price }); + processOrderSnapshot(state, makeInput({ activeOrders: [active], allOrders: [active] })); + const filled = { ...active, status: "FILLED", executedQty: 0.1 }; + processOrderSnapshot(state, makeInput({ activeOrders: [], allOrders: [filled] })); + // 价格来回穿越该线,线仍 holding → 永不再出 ENTRY + for (const price of [110, 130, 110, 130, 110]) { + const actions = planOrders(state, settings, makeInput({ price, positionAmt: 0.1 })); + expect(actions.some((a) => a.kind === "PLACE_ENTRY" && a.level === level)).toBe(false); + } + // EXIT 成交释放后才重新开放 + state.levels[level]!.phase = "idle"; + state.levels[level]!.holdQty = 0; + const actions = planOrders(state, settings, makeInput({ price: 130, positionAmt: 0 })); + expect(actions.some((a) => a.kind === "PLACE_ENTRY" && a.level === level)).toBe(true); + }); + + it("treats registered-but-never-seen orders as disappeared after timeout", () => { + const state = createInitialState(settings, 141.4); + registerIntent(state, { ...entryIntent(state, 1, "e-1"), createdAt: 1000 }); + // 16s 后订单流仍无此单 → unknown → awaiting + processOrderSnapshot(state, makeInput({ now: 17_001, activeOrders: [], allOrders: [] })); + expect(state.intents.has("e-1")).toBe(false); + expect(state.awaiting.has(1)).toBe(true); + }); +}); + +describe("resolveAwaiting", () => { + function seedAwaitingEntry(state: GridLogicState): void { + state.levels[1]!.phase = "entry_placed"; + state.awaiting.set(1, { + intent: "ENTRY", + level: 1, + side: "BUY", + qty: 0.1, + posAtStart: 0, + accountVersionAtStart: 1, + ts: 10_000, + }); + } + + it("position increase resolves awaiting ENTRY as filled", () => { + const state = createInitialState(settings, 141.4); + seedAwaitingEntry(state); + resolveAwaiting(state, makeInput({ now: 11_000, accountVersion: 2, positionAmt: 0.1 })); + expect(state.levels[1]!.phase).toBe("holding"); + expect(state.levels[1]!.holdQty).toBeCloseTo(0.1, 9); + expect(state.awaiting.size).toBe(0); + }); + + it("unchanged position after account update resolves as canceled", () => { + const state = createInitialState(settings, 141.4); + seedAwaitingEntry(state); + resolveAwaiting(state, makeInput({ now: 11_000, accountVersion: 2, positionAmt: 0 })); + expect(state.levels[1]!.phase).toBe("idle"); + expect(state.awaiting.size).toBe(0); + }); + + it("position decrease resolves awaiting EXIT as filled", () => { + const state = createInitialState(settings, 141.4); + state.levels[1]!.phase = "exit_placed"; + state.levels[1]!.holdQty = 0.1; + state.awaiting.set(1, { + intent: "EXIT", + level: 1, + side: "SELL", + qty: 0.1, + posAtStart: 0.1, + accountVersionAtStart: 1, + ts: 10_000, + }); + resolveAwaiting(state, makeInput({ now: 11_000, accountVersion: 2, positionAmt: 0 })); + expect(state.levels[1]!.phase).toBe("idle"); + expect(state.awaiting.size).toBe(0); + }); + + it("timeout without account update resolves as canceled", () => { + const state = createInitialState(settings, 141.4); + seedAwaitingEntry(state); + resolveAwaiting(state, makeInput({ now: 19_000, accountVersion: 1, positionAmt: 0 })); + expect(state.levels[1]!.phase).toBe("idle"); + expect(state.awaiting.size).toBe(0); + }); + + it("keeps awaiting while neither timeout nor account movement", () => { + const state = createInitialState(settings, 141.4); + seedAwaitingEntry(state); + resolveAwaiting(state, makeInput({ now: 12_000, accountVersion: 1, positionAmt: 0 })); + expect(state.awaiting.size).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// 止损层①②④ +// --------------------------------------------------------------------------- + +describe("checkPriceStop", () => { + it("triggers below lower and above upper thresholds", () => { + const state = createInitialState(settings, 141.4); + expect(checkPriceStop(state, settings, 98.9)).toContain("跌破"); + expect(checkPriceStop(state, settings, 202.1)).toContain("突破"); + expect(checkPriceStop(state, settings, 150)).toBeNull(); + expect(checkPriceStop(state, settings, 99.5)).toBeNull(); // 1% 容忍内 + }); + + it("uses live state bounds after a shift", () => { + const shiftSettings = { ...settings, shiftEnabled: true }; + const state = createInitialState(shiftSettings, 141.4); + applyRebuild(state, shiftSettings, 300); + expect(checkPriceStop(state, shiftSettings, 150)).not.toBeNull(); + expect(checkPriceStop(state, shiftSettings, 300)).toBeNull(); + }); +}); + +describe("auditExitCoverage", () => { + it("waits for grace period before acting", () => { + const state = createInitialState(settings, 141.4); + const input = makeInput({ now: 10_000, price: 141.4, positionAmt: 0.15, entryPrice: 140 }); + const first = auditExitCoverage(state, settings, input); + expect(first.uncoveredQty).toBeCloseTo(0.15, 9); + expect(first.action).toBeNull(); + const second = auditExitCoverage(state, settings, { ...input, now: 12_000 }); + expect(second.action).toBeNull(); + const third = auditExitCoverage(state, settings, { ...input, now: 15_100 }); + expect(third.action).not.toBeNull(); + expect(third.action!.kind).toBe("PLACE_EXIT"); + if (third.action!.kind === "PLACE_EXIT") { + expect(third.action!.source).toBe(ORPHAN_LEVEL); + expect(third.action!.side).toBe("SELL"); + expect(Number(third.action!.price)).toBeGreaterThan(140); + } + }); + + it("counts holding levels and active exits as covered", () => { + const state = createInitialState(settings, 141.4); + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + const audit = auditExitCoverage( + state, + settings, + makeInput({ positionAmt: 0.1, entryPrice: 118 }) + ); + expect(audit.uncoveredQty).toBe(0); + expect(state.uncoveredSince).toBeNull(); + }); + + it("market-closes uncovered qty when price is out of range", () => { + const state = createInitialState(settings, 141.4); + state.uncoveredSince = 1000; + const audit = auditExitCoverage( + state, + settings, + makeInput({ now: 10_000, price: 99, positionAmt: 0.15, entryPrice: 150 }) + ); + expect(audit.action).not.toBeNull(); + expect(audit.action!.kind).toBe("MARKET_CLOSE"); + if (audit.action!.kind === "MARKET_CLOSE") { + expect(audit.action!.side).toBe("SELL"); + expect(audit.action!.qty).toBeCloseTo(0.15, 9); + } + }); + + it("market-closes when floating loss exceeds stopLossPct", () => { + const state = createInitialState(settings, 141.4); + state.uncoveredSince = 1000; + const audit = auditExitCoverage( + state, + settings, + makeInput({ now: 10_000, price: 140, positionAmt: 0.15, entryPrice: 160 }) + ); + expect(audit.action!.kind).toBe("MARKET_CLOSE"); + }); + + it("short uncovered places BUY orphan exit below entry", () => { + const state = createInitialState(settings, 141.4); + state.uncoveredSince = 1000; + const audit = auditExitCoverage( + state, + settings, + makeInput({ now: 10_000, price: 160, positionAmt: -0.15, entryPrice: 160.5 }) + ); + expect(audit.action!.kind).toBe("PLACE_EXIT"); + if (audit.action!.kind === "PLACE_EXIT") { + expect(audit.action!.side).toBe("BUY"); + expect(Number(audit.action!.price)).toBeLessThan(160); + } + }); +}); + +describe("desiredExchangeStop", () => { + it("returns SELL stop below lower for net long, BUY stop above upper for net short", () => { + const state = createInitialState(settings, 141.4); + expect(desiredExchangeStop(state, settings, 0.1)).toEqual({ side: "SELL", stopPrice: 99 }); + expect(desiredExchangeStop(state, settings, -0.1)).toEqual({ side: "BUY", stopPrice: 202 }); + expect(desiredExchangeStop(state, settings, 0)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// 移格 +// --------------------------------------------------------------------------- + +describe("shift", () => { + const shiftSettings = { ...settings, shiftEnabled: true }; + + it("shouldShift debounces the trigger", () => { + const state = createInitialState(shiftSettings, 141.4); + // 偏离 >5% + expect(shouldShift(state, shiftSettings, 150, 1000)).toBe(false); // 开始计时 + expect(shouldShift(state, shiftSettings, 150, 2000)).toBe(false); // 未满 3s + expect(shouldShift(state, shiftSettings, 150, 4001)).toBe(true); // 满 3s + }); + + it("shouldShift resets when price returns inside threshold", () => { + const state = createInitialState(shiftSettings, 141.4); + shouldShift(state, shiftSettings, 150, 1000); + expect(shouldShift(state, shiftSettings, 142, 2000)).toBe(false); + expect(state.shiftCandidateSince).toBeNull(); + // 再次越限需重新计时 + expect(shouldShift(state, shiftSettings, 150, 3000)).toBe(false); + expect(shouldShift(state, shiftSettings, 150, 6001)).toBe(true); + }); + + it("shouldShift is disabled when shift already active or disabled", () => { + const state = createInitialState(shiftSettings, 141.4); + beginShift(state, 150, 0); + expect(shouldShift(state, shiftSettings, 150, 99_999)).toBe(false); + const state2 = createInitialState(settings, 141.4); + expect(shouldShift(state2, settings, 150, 0)).toBe(false); + expect(shouldShift(state2, settings, 150, 99_999)).toBe(false); + }); + + it("planShiftStep walks cancelling → closing → rebuilding idempotently", () => { + const state = createInitialState(shiftSettings, 141.4); + beginShift(state, 150, 0); + // cancelling:有挂单先撤 + expect(planShiftStep(state, shiftSettings, { activeOrderCount: 2, positionAmt: 0.1, price: 150 })).toEqual({ + kind: "CANCEL_ALL", + }); + expect(state.shift!.phase).toBe("cancelling"); + // 挂单清空 → 进入 closing + expect(planShiftStep(state, shiftSettings, { activeOrderCount: 0, positionAmt: 0.1, price: 150 })).toEqual({ + kind: "WAIT", + }); + expect(state.shift!.phase).toBe("closing"); + // closing:有仓先平 + expect(planShiftStep(state, shiftSettings, { activeOrderCount: 0, positionAmt: 0.1, price: 150 })).toEqual({ + kind: "CLOSE_POSITION", + side: "SELL", + qty: 0.1, + }); + // 仓位清零 → rebuilding + expect(planShiftStep(state, shiftSettings, { activeOrderCount: 0, positionAmt: 0, price: 150 })).toEqual({ + kind: "WAIT", + }); + expect(state.shift!.phase).toBe("rebuilding"); + expect(planShiftStep(state, shiftSettings, { activeOrderCount: 0, positionAmt: 0, price: 150 })).toEqual({ + kind: "REBUILD", + anchor: 150, + }); + }); + + it("resumes from a persisted phase (crash recovery)", () => { + const state = createInitialState(shiftSettings, 141.4); + state.shift = { phase: "closing", targetAnchor: 150, startedAt: 0 }; + const step = planShiftStep(state, shiftSettings, { activeOrderCount: 0, positionAmt: -0.2, price: 149 }); + expect(step).toEqual({ kind: "CLOSE_POSITION", side: "BUY", qty: 0.2 }); + }); + + it("applyRebuild recenters the grid and bumps gridVersion", () => { + const state = createInitialState(shiftSettings, 141.4); + state.levels[1]!.phase = "holding"; + state.intents.set("x", entryIntent(state, 1, "x")); + beginShift(state, 150, 0); + applyRebuild(state, shiftSettings, 150); + expect(state.gridVersion).toBe(2); + expect(state.anchorPrice).toBe(150); + expect(state.lowerPrice).toBeCloseTo(142.5, 6); + expect(state.upperPrice).toBeCloseTo(157.5, 6); + expect(state.shift).toBeNull(); + expect(state.intents.size).toBe(0); + expect(state.levels.every((l) => l.phase === "idle" && l.holdQty === 0)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// planTick 组合 +// --------------------------------------------------------------------------- + +describe("planTick", () => { + it("halts on price stop when shift is disabled", () => { + const state = createInitialState(settings, 141.4); + const result = planTick(state, settings, makeInput({ price: 95 })); + expect(result.actions[0]!.kind).toBe("HALT"); + }); + + it("prefers BEGIN_SHIFT over HALT when shift is enabled", () => { + const shiftSettings = { ...settings, shiftEnabled: true }; + const state = createInitialState(shiftSettings, 141.4); + const result = planTick(state, shiftSettings, makeInput({ price: 95 })); + expect(result.actions[0]!.kind).toBe("BEGIN_SHIFT"); + expect(state.shift).not.toBeNull(); + }); + + it("halts on price stop when a shift is already in progress", () => { + const shiftSettings = { ...settings, shiftEnabled: true }; + const state = createInitialState(shiftSettings, 141.4); + beginShift(state, 95, 0); + const result = planTick(state, shiftSettings, makeInput({ price: 95 })); + expect(result.actions[0]!.kind).toBe("HALT"); + }); + + it("emits entries in idle market conditions", () => { + const state = createInitialState(settings, 141.4); + const result = planTick(state, settings, makeInput({ price: 141.4 })); + expect(result.actions.some((a) => a.kind === "PLACE_ENTRY")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// reconcile +// --------------------------------------------------------------------------- + +describe("reconcile", () => { + it("keeps orders matched by orderId and rebinds levels", () => { + const state = createInitialState(settings, 141.4); + registerIntent(state, entryIntent(state, 1, "e-1")); + // 模拟重启:phase 会被重置再由挂单重建 + const order = makeOrder({ orderId: "e-1", price: state.levels[1]!.price }); + const result = reconcile(state, settings, { + activeOrders: [order], + positionAmt: 0, + price: 141.4, + now: 10_000, + }); + expect(result.cancelOrderIds).toEqual([]); + expect(state.levels[1]!.phase).toBe("entry_placed"); + expect(state.levels[1]!.entryOrderId).toBe("e-1"); + expect(state.intents.has("e-1")).toBe(true); + }); + + it("rebinds by clientOrderId when orderId is unknown", () => { + const state = createInitialState(settings, 141.4); + const order = makeOrder({ + orderId: "new-77", + clientOrderId: makeEntryClientOrderId(1, 1, 999), + price: state.levels[1]!.price, + }); + const result = reconcile(state, settings, { + activeOrders: [order], + positionAmt: 0, + price: 141.4, + now: 10_000, + }); + expect(result.cancelOrderIds).toEqual([]); + expect(state.levels[1]!.phase).toBe("entry_placed"); + expect(state.intents.get("new-77")!.intent).toBe("ENTRY"); + }); + + it("cancels stale gridVersion orders", () => { + const state = createInitialState(settings, 141.4); + state.gridVersion = 3; + const order = makeOrder({ + orderId: "old-1", + clientOrderId: makeEntryClientOrderId(2, 1, 999), + price: state.levels[1]!.price, + }); + const result = reconcile(state, settings, { + activeOrders: [order], + positionAmt: 0, + price: 141.4, + now: 10_000, + }); + expect(result.cancelOrderIds).toContain("old-1"); + }); + + it("falls back to side+price matching and prefers EXIT on ambiguity", () => { + const state = createInitialState(settings, 141.4); + // 线 2 holding,其 exit 目标 3(SELL@159.5);一张无 cid 的 SELL@159.5 应判为 EXIT 而非线 3 的 ENTRY + state.levels[2]!.phase = "holding"; + state.levels[2]!.holdQty = 0.1; + const order = makeOrder({ orderId: "anon-1", side: "SELL", price: state.levels[3]!.price }); + const result = reconcile(state, settings, { + activeOrders: [order], + positionAmt: 0.1, + price: 150, + now: 10_000, + }); + expect(result.cancelOrderIds).toEqual([]); + expect(state.levels[2]!.phase).toBe("exit_placed"); + expect(state.intents.get("anon-1")!.intent).toBe("EXIT"); + expect(state.intents.get("anon-1")!.level).toBe(2); + }); + + it("adopts price-aligned entries without cid", () => { + const state = createInitialState(settings, 141.4); + const order = makeOrder({ orderId: "anon-2", side: "BUY", price: state.levels[0]!.price }); + reconcile(state, settings, { activeOrders: [order], positionAmt: 0, price: 141.4, now: 10_000 }); + expect(state.levels[0]!.phase).toBe("entry_placed"); + expect(state.intents.get("anon-2")!.intent).toBe("ENTRY"); + }); + + it("adopts closing-side strangers as orphan EXIT and cancels the rest", () => { + const state = createInitialState(settings, 141.4); + const closer = makeOrder({ orderId: "s-1", side: "SELL", price: 155.5 }); + const noise = makeOrder({ orderId: "s-2", side: "BUY", price: 133.3 }); + const result = reconcile(state, settings, { + activeOrders: [closer, noise], + positionAmt: 0.2, + price: 150, + now: 10_000, + }); + expect(state.intents.get("s-1")!.level).toBe(ORPHAN_LEVEL); + expect(result.cancelOrderIds).toContain("s-2"); + }); + + it("claims inflight slot by clientOrderId", () => { + const state = createInitialState(settings, 141.4); + state.inflight = { + clientOrderId: "grid-1-E-1-ff", + intent: "ENTRY", + side: "BUY", + price: state.levels[1]!.price.toFixed(1), + qty: 0.1, + level: 1, + gridVersion: 1, + createdAt: 9000, + }; + const order = makeOrder({ + orderId: "srv-9", + clientOrderId: "grid-1-E-1-ff", + price: state.levels[1]!.price, + }); + reconcile(state, settings, { activeOrders: [order], positionAmt: 0, price: 141.4, now: 10_000 }); + expect(state.inflight).toBeNull(); + expect(state.levels[1]!.phase).toBe("entry_placed"); + expect(state.intents.get("srv-9")).toBeTruthy(); + }); + + it("clears inflight when no matching order exists", () => { + const state = createInitialState(settings, 141.4); + state.inflight = { + clientOrderId: "grid-1-E-1-ff", + intent: "ENTRY", + side: "BUY", + price: "118.9", + qty: 0.1, + level: 1, + gridVersion: 1, + createdAt: 9000, + }; + reconcile(state, settings, { activeOrders: [], positionAmt: 0, price: 141.4, now: 10_000 }); + expect(state.inflight).toBeNull(); + }); + + it("allocates position surplus to nearest idle lines, residue becomes orphan", () => { + const state = createInitialState(settings, 141.4); + // 实际净多 0.25,但没有任何线 holding → 归档到最近 BUY 线(每线 ≤ 0.1),残余 0.05 + const result = reconcile(state, settings, { + activeOrders: [], + positionAmt: 0.25, + price: 141.4, + now: 10_000, + }); + const holding = state.levels.filter((l) => l.phase === "holding"); + expect(holding.length).toBe(3); + const total = holding.reduce((acc, l) => acc + l.holdQty, 0); + expect(total).toBeCloseTo(0.25, 9); + expect(result.orphanQty).toBe(0); + }); + + it("reports orphan when position exceeds line capacity", () => { + const state = createInitialState(settings, 141.4); + // neutral 下 BUY 线只有 3 条(0/1/2),容量 0.3;净多 0.5 → 残余 0.2 + const result = reconcile(state, settings, { + activeOrders: [], + positionAmt: 0.5, + price: 141.4, + now: 10_000, + }); + expect(result.orphanQty).toBeCloseTo(0.2, 9); + expect(state.uncoveredSince).not.toBeNull(); + }); + + it("releases holds when actual position is below expectation", () => { + const state = createInitialState(settings, 141.4); + state.levels[0]!.phase = "holding"; + state.levels[0]!.holdQty = 0.1; + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + // 实际仓位 0.1 → 释放 exit 目标价离现价最近的线 1(其 EXIT 最可能已成交),保留线 0 + reconcile(state, settings, { activeOrders: [], positionAmt: 0.1, price: 141.4, now: 10_000 }); + const holding = state.levels.filter((l) => l.phase === "holding"); + expect(holding).toHaveLength(1); + expect(holding[0]!.index).toBe(0); + expect(state.levels[1]!.phase).toBe("idle"); + }); + + it("restores from v1-migrated stored state and reconciles", () => { + // v1 迁移:filled→holding,无 intents,靠价档兜底重建 + const stored = { + schemaVersion: 2 as const, + symbol: "BTCUSDT", + exchangeId: "", + gridVersion: 1, + anchorPrice: null, + lowerPrice: 100, + upperPrice: 200, + gridLevels: 5, + orderSize: 0.1, + maxPositionSize: 0.4, + direction: "both", + gridMode: "geometric", + levels: { "1": { phase: "holding" as const, exitTarget: 2, holdQty: 0.1 } }, + intents: [], + updatedAt: 0, + }; + expect(isCompatibleStoredState(stored, meta)).toBe(true); + const state = fromStored(stored, settings, 141.4); + expect(state.anchorPrice).toBe(141.4); + expect(state.levels[1]!.phase).toBe("holding"); + const exitOrder = makeOrder({ orderId: "leg-1", side: "SELL", price: state.levels[2]!.price }); + reconcile(state, settings, { + activeOrders: [exitOrder], + positionAmt: 0.1, + price: 141.4, + now: 10_000, + }); + expect(state.levels[1]!.phase).toBe("exit_placed"); + expect(state.intents.get("leg-1")!.intent).toBe("EXIT"); + }); +}); + +// --------------------------------------------------------------------------- +// 持久化转换 +// --------------------------------------------------------------------------- + +describe("stored state round-trip", () => { + it("toStored → fromStored preserves phases, intents, shift and stop", () => { + const state = createInitialState(settings, 141.4); + state.gridVersion = 4; + state.levels[1]!.phase = "holding"; + state.levels[1]!.holdQty = 0.1; + registerIntent(state, exitIntent(state, 1, "x-5")); + state.shift = { phase: "closing", targetAnchor: 155, startedAt: 123 }; + state.exchangeStop = { orderId: "st-1", side: "SELL", stopPrice: 99 }; + const stored = toStored(state, meta, 999); + expect(stored.schemaVersion).toBe(2); + expect(stored.gridVersion).toBe(4); + const revived = fromStored(stored, settings, 0); + expect(revived.gridVersion).toBe(4); + expect(revived.anchorPrice).toBeCloseTo(141.4, 6); + expect(revived.levels[1]!.phase).toBe("exit_placed"); + expect(revived.levels[1]!.holdQty).toBeCloseTo(0.1, 9); + expect(revived.intents.get("x-5")!.target).toBe(2); + expect(revived.shift).toEqual({ phase: "closing", targetAnchor: 155, startedAt: 123 }); + expect(revived.exchangeStop).toEqual({ orderId: "st-1", side: "SELL", stopPrice: 99 }); + }); + + it("isCompatibleStoredState rejects fingerprint mismatches", () => { + const state = createInitialState(settings, 141.4); + const stored = toStored(state, meta, 0); + expect(isCompatibleStoredState(stored, meta)).toBe(true); + expect(isCompatibleStoredState(stored, { ...meta, direction: "long" })).toBe(false); + expect(isCompatibleStoredState(stored, { ...meta, orderSize: 0.2 })).toBe(false); + expect(isCompatibleStoredState(stored, { ...meta, gridLevels: 6 })).toBe(false); + expect(isCompatibleStoredState(stored, { ...meta, symbol: "ETHUSDT" })).toBe(false); + expect(isCompatibleStoredState(stored, { ...meta, exchangeId: "grvt" })).toBe(false); + }); + + it("stored bounds win over settings (post-shift restart)", () => { + const shiftSettings = { ...settings, shiftEnabled: true }; + const state = createInitialState(shiftSettings, 141.4); + applyRebuild(state, shiftSettings, 300); + const stored = toStored(state, meta, 0); + const revived = fromStored(stored, shiftSettings, 300); + expect(revived.lowerPrice).toBeCloseTo(285, 6); + expect(revived.upperPrice).toBeCloseTo(315, 6); + expect(revived.gridVersion).toBe(2); + }); +}); diff --git a/src/strategy/grid-logic.ts b/src/strategy/grid-logic.ts new file mode 100644 index 0000000..ea4931b --- /dev/null +++ b/src/strategy/grid-logic.ts @@ -0,0 +1,1347 @@ +// 网格纯逻辑:无 I/O、无 Date.now、无 adapter 引用。所有时间通过参数传入。 +// 引擎每 tick 调 planTick(state, settings, input) 得到 actions,由引擎负责执行。 + +export type Side = "BUY" | "SELL"; +export type GridTradeMode = "long" | "short" | "neutral"; +export type LevelPhase = "idle" | "entry_placed" | "holding" | "exit_placed"; +export type OrderIntentKind = "ENTRY" | "EXIT"; +export type ShiftPhase = "cancelling" | "closing" | "rebuilding"; + +/** EXIT intent 的 level 为该值时表示孤儿平仓单(不属于任何网格线) */ +export const ORPHAN_LEVEL = -1; + +const PRICE_EPS = 1e-8; +const AWAITING_TIMEOUT_MS = 8_000; +/** 下单成功但订单流从未出现该订单的判定超时 */ +const NEVER_SEEN_TIMEOUT_MS = 15_000; + +// --------------------------------------------------------------------------- +// Settings / State / Input +// --------------------------------------------------------------------------- + +export interface GridLogicSettings { + direction: GridTradeMode; + lowerPrice: number; + upperPrice: number; + gridLevels: number; + orderSize: number; + maxPositionSize: number; + priceTick: number; + qtyStep: number; + stopLossPct: number; + uncoveredGraceMs: number; + shiftEnabled: boolean; + shiftTriggerPct: number; + shiftRangePct: number; + shiftConfirmMs: number; +} + +export interface OrderIntentRecord { + orderId: string; + clientOrderId?: string; + intent: OrderIntentKind; + side: Side; + price: string; + qty: number; + /** ENTRY: 开仓线;EXIT: 源线(ORPHAN_LEVEL 表示孤儿平仓) */ + level: number; + /** EXIT: 目标线 */ + target?: number; + gridVersion: number; + createdAt: number; +} + +export interface LevelRuntime { + index: number; + price: number; + /** null = 该线不开仓(long 顶线 / short 底线 / neutral 边界外) */ + entrySide: Side | null; + /** 平仓目标 = 相邻线 */ + exitTarget: number | null; + phase: LevelPhase; + entryOrderId?: string; + exitOrderId?: string; + holdQty: number; +} + +export interface AwaitingInfo { + intent: OrderIntentKind; + level: number; + side: Side; + qty: number; + posAtStart: number; + accountVersionAtStart: number; + ts: number; +} + +export interface InflightRecord { + clientOrderId: string; + intent: OrderIntentKind; + side: Side; + price: string; + qty: number; + level: number; + target?: number; + gridVersion: number; + createdAt: number; +} + +export interface ExchangeStopState { + orderId: string; + side: Side; + stopPrice: number; +} + +export interface GridLogicState { + gridVersion: number; + anchorPrice: number; + lowerPrice: number; + upperPrice: number; + levels: LevelRuntime[]; + intents: Map; + awaiting: Map; + inflight: InflightRecord | null; + shift: { phase: ShiftPhase; targetAnchor: number; startedAt: number } | null; + exchangeStop: ExchangeStopState | null; + /** 运行时字段(不持久化) */ + prevActiveIds: Set; + seenOrderIds: Set; + shiftCandidateSince: number | null; + uncoveredSince: number | null; +} + +export interface OrderView { + orderId: string; + clientOrderId?: string; + side: Side; + price: number; + status: string; + executedQty: number; + origQty: number; + type: string; +} + +export interface GridTickInput { + now: number; + price: number; + positionAmt: number; + entryPrice: number; + accountVersion: number; + /** 本 symbol 的活跃限价单(不含交易所兜底止损单) */ + activeOrders: OrderView[]; + /** 本 symbol 的全部订单快照(含终态,用于消失分类) */ + allOrders: OrderView[]; +} + +export type GridPlanAction = + | { kind: "PLACE_ENTRY"; level: number; side: Side; price: string; qty: number } + | { + kind: "PLACE_EXIT"; + source: number; + target: number | null; + side: Side; + price: string; + qty: number; + } + | { kind: "MARKET_CLOSE"; side: Side; qty: number; reason: string } + | { kind: "HALT"; reason: string } + | { kind: "BEGIN_SHIFT"; targetAnchor: number }; + +export interface GridPlanResult { + actions: GridPlanAction[]; + events: string[]; + stateChanged: boolean; + /** 层②审计得到的未覆盖仓位(供快照展示) */ + uncoveredQty: number; +} + +// --------------------------------------------------------------------------- +// 持久化 schema(v2)。文件读写在 grid-storage.ts。 +// --------------------------------------------------------------------------- + +export interface StoredLevelV2 { + phase: LevelPhase; + entryOrderId?: string; + exitOrderId?: string; + exitTarget?: number | null; + holdQty: number; +} + +export interface StoredGridStateV2 { + schemaVersion: 2; + symbol: string; + exchangeId: string; + gridVersion: number; + /** null = v1 迁移而来,无锚定价,首个行情价补齐 */ + anchorPrice: number | null; + lowerPrice: number; + upperPrice: number; + gridLevels: number; + orderSize: number; + maxPositionSize: number; + direction: string; + gridMode: string; + levels: Record; + intents: OrderIntentRecord[]; + inflight?: InflightRecord | null; + shift?: { phase: ShiftPhase; targetAnchor: number; startedAt: number } | null; + exchangeStop?: ExchangeStopState | null; + updatedAt: number; +} + +// --------------------------------------------------------------------------- +// 基础工具 +// --------------------------------------------------------------------------- + +export function qtyEpsilon(settings: Pick): number { + return Math.max(settings.qtyStep / 2, 1e-9); +} + +function priceDecimalsOf(tick: number): number { + if (!Number.isFinite(tick) || tick <= 0) return 2; + const text = tick.toString(); + if (text.includes("e") || text.includes("E")) { + const abs = Math.abs(Math.log10(tick)); + return Math.min(Math.ceil(abs), 12); + } + const dot = text.indexOf("."); + return dot < 0 ? 0 : text.length - dot - 1; +} + +export function formatPrice(price: number, priceTick: number): string { + if (!Number.isFinite(price)) return "0"; + return price.toFixed(priceDecimalsOf(priceTick)); +} + +export function computeLevelPrices( + lowerPrice: number, + upperPrice: number, + count: number, + priceTick: number +): number[] { + if (!(lowerPrice > 0) || !(upperPrice > lowerPrice) || !Number.isFinite(count) || count < 2) { + return []; + } + const decimals = priceDecimalsOf(priceTick); + const ratio = Math.pow(upperPrice / lowerPrice, 1 / (count - 1)); + const levels: number[] = []; + for (let i = 0; i < count; i += 1) { + levels.push(Number((lowerPrice * Math.pow(ratio, i)).toFixed(decimals))); + } + levels[0] = Number(lowerPrice.toFixed(decimals)); + levels[levels.length - 1] = Number(upperPrice.toFixed(decimals)); + return levels; +} + +export interface LevelRole { + entrySide: Side | null; + exitTarget: number | null; +} + +/** 三模式角色分配:平仓目标恒为相邻线 */ +export function assignRoles( + prices: number[], + direction: GridTradeMode, + anchorPrice: number +): LevelRole[] { + return prices.map((price, i) => { + const hasUp = i + 1 < prices.length; + const hasDown = i > 0; + if (direction === "long") { + return hasUp + ? { entrySide: "BUY" as Side, exitTarget: i + 1 } + : { entrySide: null, exitTarget: null }; + } + if (direction === "short") { + return hasDown + ? { entrySide: "SELL" as Side, exitTarget: i - 1 } + : { entrySide: null, exitTarget: null }; + } + // neutral:锚定价下方(含)挂多、上方挂空 + if (price <= anchorPrice + PRICE_EPS) { + return hasUp + ? { entrySide: "BUY" as Side, exitTarget: i + 1 } + : { entrySide: null, exitTarget: null }; + } + return hasDown + ? { entrySide: "SELL" as Side, exitTarget: i - 1 } + : { entrySide: null, exitTarget: null }; + }); +} + +function buildLevels( + prices: number[], + direction: GridTradeMode, + anchorPrice: number +): LevelRuntime[] { + const roles = assignRoles(prices, direction, anchorPrice); + return prices.map((price, index) => ({ + index, + price, + entrySide: roles[index]!.entrySide, + exitTarget: roles[index]!.exitTarget, + phase: "idle" as LevelPhase, + holdQty: 0, + })); +} + +export function clampPrice(price: number, lowerPrice: number, upperPrice: number): number { + return Math.min(Math.max(price, lowerPrice), upperPrice); +} + +export function createInitialState( + settings: GridLogicSettings, + anchorPrice: number, + gridVersion = 1 +): GridLogicState { + const anchor = clampPrice(anchorPrice, settings.lowerPrice, settings.upperPrice); + const prices = computeLevelPrices( + settings.lowerPrice, + settings.upperPrice, + settings.gridLevels, + settings.priceTick + ); + return { + gridVersion, + anchorPrice: anchor, + lowerPrice: settings.lowerPrice, + upperPrice: settings.upperPrice, + levels: buildLevels(prices, settings.direction, anchor), + intents: new Map(), + awaiting: new Map(), + inflight: null, + shift: null, + exchangeStop: null, + prevActiveIds: new Set(), + seenOrderIds: new Set(), + shiftCandidateSince: null, + uncoveredSince: null, + }; +} + +// --------------------------------------------------------------------------- +// clientOrderId 编解码(gridVersion 用于识别移格后旧格残留单) +// --------------------------------------------------------------------------- + +const CID_PREFIX = "grid"; + +export function makeEntryClientOrderId(gridVersion: number, level: number, now: number): string { + return `${CID_PREFIX}-${gridVersion}-E-${level}-${now.toString(16)}`; +} + +export function makeExitClientOrderId( + gridVersion: number, + source: number, + target: number, + now: number +): string { + return `${CID_PREFIX}-${gridVersion}-X-${source}-${target}-${now.toString(16)}`; +} + +export interface ParsedClientOrderId { + intent: OrderIntentKind; + /** null = 旧版编码(无 gridVersion) */ + gridVersion: number | null; + level: number; + target?: number; +} + +export function parseClientOrderId(cid: string | undefined | null): ParsedClientOrderId | null { + if (!cid || !cid.startsWith(`${CID_PREFIX}-`)) return null; + const parts = cid.split("-"); + // 新版:grid-{v}-E-{level}-{ts} / grid-{v}-X-{src}-{tgt}-{ts} + const version = Number(parts[1]); + if (Number.isFinite(version) && (parts[2] === "E" || parts[2] === "X")) { + if (parts[2] === "E" && parts.length >= 4) { + const level = Number(parts[3]); + if (!Number.isFinite(level)) return null; + return { intent: "ENTRY", gridVersion: version, level }; + } + if (parts[2] === "X" && parts.length >= 5) { + const source = Number(parts[3]); + const target = Number(parts[4]); + if (!Number.isFinite(source) || !Number.isFinite(target)) return null; + return { intent: "EXIT", gridVersion: version, level: source, target }; + } + return null; + } + // 旧版:grid-E-{level}-{ts} / grid-X-{src}-{tgt}-{ts} + if (parts[1] === "E" && parts.length >= 3) { + const level = Number(parts[2]); + if (!Number.isFinite(level)) return null; + return { intent: "ENTRY", gridVersion: null, level }; + } + if (parts[1] === "X" && parts.length >= 4) { + const source = Number(parts[2]); + const target = Number(parts[3]); + if (!Number.isFinite(source) || !Number.isFinite(target)) return null; + return { intent: "EXIT", gridVersion: null, level: source, target }; + } + return null; +} + +// --------------------------------------------------------------------------- +// 订单消失分类 + awaiting 裁决 +// --------------------------------------------------------------------------- + +const FINAL_CANCEL_STATUSES = new Set(["CANCELED", "CANCELLED", "EXPIRED", "REJECTED"]); + +export type DisappearClass = "filled" | "canceled" | "unknown"; + +export function classifyDisappearance(record: OrderView | undefined): { + cls: DisappearClass; + executedQty: number; +} { + if (!record) return { cls: "unknown", executedQty: 0 }; + const status = String(record.status || "").toUpperCase(); + const executed = Number(record.executedQty || 0); + if (status === "FILLED" || executed > PRICE_EPS) { + return { cls: "filled", executedQty: executed }; + } + if (FINAL_CANCEL_STATUSES.has(status)) { + return { cls: "canceled", executedQty: 0 }; + } + return { cls: "unknown", executedQty: 0 }; +} + +function applyFilled( + state: GridLogicState, + intent: OrderIntentRecord, + executedQty: number, + events: string[] +): void { + const qty = executedQty > PRICE_EPS ? executedQty : intent.qty; + if (intent.intent === "ENTRY") { + const level = state.levels[intent.level]; + if (level) { + level.phase = "holding"; + level.holdQty = qty; + delete level.entryOrderId; + events.push(`ENTRY 成交: ${intent.side} @ ${intent.price} (线 ${intent.level})`); + } + } else { + if (intent.level === ORPHAN_LEVEL) { + events.push(`孤儿 EXIT 成交: ${intent.side} @ ${intent.price}`); + return; + } + const level = state.levels[intent.level]; + if (level) { + level.phase = "idle"; + level.holdQty = 0; + delete level.exitOrderId; + events.push(`EXIT 成交: ${intent.side} @ ${intent.price} (释放线 ${intent.level})`); + } + } +} + +function applyCanceled(state: GridLogicState, intent: OrderIntentRecord, events: string[]): void { + if (intent.intent === "ENTRY") { + const level = state.levels[intent.level]; + if (level && level.phase === "entry_placed") { + level.phase = "idle"; + delete level.entryOrderId; + } + events.push(`ENTRY 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`); + } else { + if (intent.level === ORPHAN_LEVEL) return; + const level = state.levels[intent.level]; + if (level && level.phase === "exit_placed") { + level.phase = "holding"; + delete level.exitOrderId; + } + events.push(`EXIT 撤销: ${intent.side} @ ${intent.price} (线 ${intent.level})`); + } +} + +function setAwaiting(state: GridLogicState, intent: OrderIntentRecord, input: GridTickInput): void { + state.awaiting.set(intent.level, { + intent: intent.intent, + level: intent.level, + side: intent.side, + qty: intent.qty, + posAtStart: input.positionAmt, + accountVersionAtStart: input.accountVersion, + ts: input.now, + }); +} + +/** 处理订单快照:检测消失订单并按 filled/canceled/unknown 三分支迁移线状态 */ +export function processOrderSnapshot( + state: GridLogicState, + input: GridTickInput +): { events: string[]; changed: boolean } { + const events: string[] = []; + let changed = false; + const currIds = new Set(); + for (const order of input.activeOrders) { + currIds.add(order.orderId); + if (state.intents.has(order.orderId)) state.seenOrderIds.add(order.orderId); + } + const allById = new Map(); + for (const order of input.allOrders) allById.set(order.orderId, order); + + const disappeared: string[] = []; + for (const id of state.prevActiveIds) { + if (!currIds.has(id)) disappeared.push(id); + } + // 已登记但订单流从未出现且超时的订单,一并进入判定 + for (const [id, intent] of state.intents) { + if (currIds.has(id) || state.seenOrderIds.has(id)) continue; + if (input.now - intent.createdAt > NEVER_SEEN_TIMEOUT_MS && !disappeared.includes(id)) { + disappeared.push(id); + } + } + + for (const id of disappeared) { + const intent = state.intents.get(id); + if (!intent) continue; + const { cls, executedQty } = classifyDisappearance(allById.get(id)); + if (cls === "filled") { + applyFilled(state, intent, executedQty, events); + } else if (cls === "canceled") { + applyCanceled(state, intent, events); + } else { + setAwaiting(state, intent, input); + events.push(`订单消失待判定: ${intent.intent} ${intent.side} @ ${intent.price}`); + } + state.intents.delete(id); + state.seenOrderIds.delete(id); + changed = true; + } + + state.prevActiveIds = currIds; + return { events, changed }; +} + +/** awaiting 裁决:按账户仓位差三分支(增→ENTRY 成交 / 减→EXIT 成交 / 不变→撤销),超时按撤销处理 */ +export function resolveAwaiting( + state: GridLogicState, + input: GridTickInput +): { events: string[]; changed: boolean } { + const events: string[] = []; + let changed = false; + for (const [key, info] of Array.from(state.awaiting.entries())) { + const timedOut = input.now - info.ts > AWAITING_TIMEOUT_MS; + const accountAdvanced = input.accountVersion > info.accountVersionAtStart; + if (!timedOut && !accountAdvanced) continue; + + const delta = input.positionAmt - info.posAtStart; + const expectedSign = info.side === "BUY" ? 1 : -1; + const movedAsExpected = delta * expectedSign > PRICE_EPS; + + const pseudoIntent: OrderIntentRecord = { + orderId: "", + intent: info.intent, + side: info.side, + price: "?", + qty: info.qty, + level: info.level, + gridVersion: state.gridVersion, + createdAt: info.ts, + }; + if (accountAdvanced && movedAsExpected) { + applyFilled(state, pseudoIntent, Math.min(Math.abs(delta), info.qty), events); + } else if (accountAdvanced || timedOut) { + applyCanceled(state, pseudoIntent, events); + } else { + continue; + } + state.awaiting.delete(key); + changed = true; + } + return { events, changed }; +} + +// --------------------------------------------------------------------------- +// 数量约束 +// --------------------------------------------------------------------------- + +/** remain = maxPositionSize − |同方向净仓| − Σ(同方向活跃 ENTRY 未成交量),neutral 按两侧分别约束 */ +export function capEntryQty( + state: GridLogicState, + settings: GridLogicSettings, + side: Side, + positionAmt: number, + desiredQty: number +): number { + const sameDirNet = side === "BUY" ? Math.max(positionAmt, 0) : Math.max(-positionAmt, 0); + let inflightEntry = 0; + for (const intent of state.intents.values()) { + if (intent.intent === "ENTRY" && intent.side === side) inflightEntry += intent.qty; + } + if (state.inflight && state.inflight.intent === "ENTRY" && state.inflight.side === side) { + inflightEntry += state.inflight.qty; + } + const remain = Math.max(settings.maxPositionSize - sameDirNet - inflightEntry, 0); + return Math.min(desiredQty, remain); +} + +function sumActiveExitQty(state: GridLogicState, side: Side): number { + let total = 0; + for (const intent of state.intents.values()) { + if (intent.intent === "EXIT" && intent.side === side) total += intent.qty; + } + if (state.inflight && state.inflight.intent === "EXIT" && state.inflight.side === side) { + total += state.inflight.qty; + } + return total; +} + +// --------------------------------------------------------------------------- +// 挂单规划(每线一单不变量在此保证) +// --------------------------------------------------------------------------- + +export function planOrders( + state: GridLogicState, + settings: GridLogicSettings, + input: GridTickInput +): GridPlanAction[] { + const actions: GridPlanAction[] = []; + const eps = qtyEpsilon(settings); + const halfTick = settings.priceTick / 2; + const absPos = Math.abs(input.positionAmt); + + // 1) EXIT 优先:holding 线补挂平仓单,配对目标 = 相邻线 + let exitBudgetSell = Math.max(absPos * (input.positionAmt > 0 ? 1 : 0) - sumActiveExitQty(state, "SELL"), 0); + let exitBudgetBuy = Math.max(absPos * (input.positionAmt < 0 ? 1 : 0) - sumActiveExitQty(state, "BUY"), 0); + for (const level of state.levels) { + if (level.phase !== "holding") continue; + if (state.awaiting.has(level.index)) continue; + if (level.entrySide == null || level.exitTarget == null) continue; + const target = state.levels[level.exitTarget]; + if (!target) continue; + const exitSide: Side = level.entrySide === "BUY" ? "SELL" : "BUY"; + const budget = exitSide === "SELL" ? exitBudgetSell : exitBudgetBuy; + const qty = Math.min(level.holdQty, budget); + if (qty <= eps) continue; + if (exitSide === "SELL") exitBudgetSell -= qty; + else exitBudgetBuy -= qty; + actions.push({ + kind: "PLACE_EXIT", + source: level.index, + target: level.exitTarget, + side: exitSide, + price: formatPrice(target.price, settings.priceTick), + qty, + }); + } + + // 2) ENTRY:仅 idle 且无 awaiting 的线;价格反复穿越期间 holding/exit_placed 线对 ENTRY 关闭。 + // 同一价位允许本线 ENTRY 与相邻线的 EXIT 并存(中性网格向上穿越 = 平多 + 开空)。 + const entryCandidates: Array<{ level: LevelRuntime; distance: number }> = []; + for (const level of state.levels) { + if (level.entrySide == null) continue; + if (level.phase !== "idle") continue; + if (state.awaiting.has(level.index)) continue; + if (level.entrySide === "BUY") { + if (level.price >= input.price - halfTick) continue; + } else { + if (level.price <= input.price + halfTick) continue; + } + entryCandidates.push({ level, distance: Math.abs(level.price - input.price) }); + } + entryCandidates.sort((a, b) => a.distance - b.distance); + for (const { level } of entryCandidates) { + const side = level.entrySide!; + const qty = capEntryQty(state, settings, side, input.positionAmt, settings.orderSize); + if (qty <= eps) continue; + actions.push({ + kind: "PLACE_ENTRY", + level: level.index, + side, + price: formatPrice(level.price, settings.priceTick), + qty, + }); + } + return actions; +} + +// --------------------------------------------------------------------------- +// 止损层①:价格越界 +// --------------------------------------------------------------------------- + +export function checkPriceStop( + state: GridLogicState, + settings: GridLogicSettings, + price: number +): string | null { + if (settings.stopLossPct <= 0) return null; + const lowerTrigger = state.lowerPrice * (1 - settings.stopLossPct); + const upperTrigger = state.upperPrice * (1 + settings.stopLossPct); + if (price <= lowerTrigger) { + return `价格跌破网格下边界 ${((1 - price / state.lowerPrice) * 100).toFixed(2)}%`; + } + if (price >= upperTrigger) { + return `价格突破网格上边界 ${((price / state.upperPrice - 1) * 100).toFixed(2)}%`; + } + return null; +} + +// --------------------------------------------------------------------------- +// 止损层②:持仓覆盖审计 +// --------------------------------------------------------------------------- + +export interface CoverageAudit { + uncoveredQty: number; + action: GridPlanAction | null; + events: string[]; +} + +export function auditExitCoverage( + state: GridLogicState, + settings: GridLogicSettings, + input: GridTickInput +): CoverageAudit { + const eps = qtyEpsilon(settings); + const pos = input.positionAmt; + if (Math.abs(pos) <= eps) { + state.uncoveredSince = null; + return { uncoveredQty: 0, action: null, events: [] }; + } + const exitSide: Side = pos > 0 ? "SELL" : "BUY"; + const entrySide: Side = pos > 0 ? "BUY" : "SELL"; + const coveredByOrders = sumActiveExitQty(state, exitSide); + let plannedCover = 0; + for (const level of state.levels) { + if (level.phase !== "holding") continue; + if (level.entrySide !== entrySide) continue; + plannedCover += level.holdQty; + } + const uncovered = Math.abs(pos) - coveredByOrders - plannedCover; + if (uncovered <= eps) { + state.uncoveredSince = null; + return { uncoveredQty: 0, action: null, events: [] }; + } + if (state.uncoveredSince == null) { + state.uncoveredSince = input.now; + return { uncoveredQty: uncovered, action: null, events: [] }; + } + if (input.now - state.uncoveredSince < settings.uncoveredGraceMs) { + return { uncoveredQty: uncovered, action: null, events: [] }; + } + + const events: string[] = []; + const outOfRange = input.price < state.lowerPrice || input.price > state.upperPrice; + const entry = input.entryPrice; + let deepLoss = false; + if (settings.stopLossPct > 0 && Number.isFinite(entry) && entry > 0) { + const lossPct = pos > 0 ? (entry - input.price) / entry : (input.price - entry) / entry; + deepLoss = lossPct > settings.stopLossPct; + } + state.uncoveredSince = input.now; + if (outOfRange || deepLoss) { + events.push( + `覆盖审计: 未覆盖 ${uncovered.toFixed(6)} 且${outOfRange ? "价格已出区间" : "浮亏超限"},市价平仓` + ); + return { + uncoveredQty: uncovered, + action: { kind: "MARKET_CLOSE", side: exitSide, qty: uncovered, reason: "覆盖审计止损" }, + events, + }; + } + // 最近可盈利线补挂孤儿 EXIT + const targetPrice = findNearestProfitableExitPrice(state, pos > 0 ? "long" : "short", entry, input.price); + events.push(`覆盖审计: 未覆盖 ${uncovered.toFixed(6)},补挂平仓单 @ ${targetPrice}`); + return { + uncoveredQty: uncovered, + action: { + kind: "PLACE_EXIT", + source: ORPHAN_LEVEL, + target: null, + side: exitSide, + price: formatPrice(targetPrice, settings.priceTick), + qty: uncovered, + }, + events, + }; +} + +function findNearestProfitableExitPrice( + state: GridLogicState, + direction: "long" | "short", + entryPrice: number, + price: number +): number { + const ref = Number.isFinite(entryPrice) && entryPrice > 0 ? entryPrice : price; + if (direction === "long") { + for (const level of state.levels) { + if (level.price > ref + PRICE_EPS && level.price > price + PRICE_EPS) return level.price; + } + return state.upperPrice; + } + for (let i = state.levels.length - 1; i >= 0; i -= 1) { + const level = state.levels[i]!; + if (level.price < ref - PRICE_EPS && level.price < price - PRICE_EPS) return level.price; + } + return state.lowerPrice; +} + +// --------------------------------------------------------------------------- +// 止损层④:交易所侧 STOP_MARKET 兜底 +// --------------------------------------------------------------------------- + +export function desiredExchangeStop( + state: GridLogicState, + settings: GridLogicSettings, + positionAmt: number +): { side: Side; stopPrice: number } | null { + if (settings.stopLossPct <= 0) return null; + const eps = qtyEpsilon(settings); + if (positionAmt > eps) { + return { side: "SELL", stopPrice: state.lowerPrice * (1 - settings.stopLossPct) }; + } + if (positionAmt < -eps) { + return { side: "BUY", stopPrice: state.upperPrice * (1 + settings.stopLossPct) }; + } + return null; +} + +// --------------------------------------------------------------------------- +// 智能移格 +// --------------------------------------------------------------------------- + +/** 触发去抖:偏离超阈值持续 confirmMs 才触发 */ +export function shouldShift( + state: GridLogicState, + settings: GridLogicSettings, + price: number, + now: number +): boolean { + if (!settings.shiftEnabled || state.shift) return false; + if (!(state.anchorPrice > 0)) return false; + const deviation = Math.abs(price / state.anchorPrice - 1); + if (deviation < settings.shiftTriggerPct) { + state.shiftCandidateSince = null; + return false; + } + if (state.shiftCandidateSince == null) { + state.shiftCandidateSince = now; + return false; + } + return now - state.shiftCandidateSince >= settings.shiftConfirmMs; +} + +export function beginShift(state: GridLogicState, targetAnchor: number, now: number): void { + state.shift = { phase: "cancelling", targetAnchor, startedAt: now }; + state.shiftCandidateSince = null; +} + +export type ShiftStep = + | { kind: "CANCEL_ALL" } + | { kind: "CLOSE_POSITION"; side: Side; qty: number } + | { kind: "REBUILD"; anchor: number } + | { kind: "WAIT" }; + +export interface ShiftStepInput { + activeOrderCount: number; + positionAmt: number; + price: number; +} + +/** 三阶段幂等推进:每步先确认前置条件再前进,崩溃后从持久化 phase 续跑 */ +export function planShiftStep( + state: GridLogicState, + settings: GridLogicSettings, + input: ShiftStepInput +): ShiftStep { + if (!state.shift) return { kind: "WAIT" }; + const eps = qtyEpsilon(settings); + if (state.shift.phase === "cancelling") { + if (input.activeOrderCount > 0) return { kind: "CANCEL_ALL" }; + state.shift.phase = "closing"; + return { kind: "WAIT" }; + } + if (state.shift.phase === "closing") { + if (Math.abs(input.positionAmt) > eps) { + return { + kind: "CLOSE_POSITION", + side: input.positionAmt > 0 ? "SELL" : "BUY", + qty: Math.abs(input.positionAmt), + }; + } + state.shift.phase = "rebuilding"; + return { kind: "WAIT" }; + } + const anchor = Number.isFinite(input.price) && input.price > 0 ? input.price : state.shift.targetAnchor; + return { kind: "REBUILD", anchor }; +} + +/** 以新锚定价重建网格:gridVersion+1、全线重置、清 intents/awaiting */ +export function applyRebuild( + state: GridLogicState, + settings: GridLogicSettings, + anchor: number +): void { + const lower = anchor * (1 - settings.shiftRangePct); + const upper = anchor * (1 + settings.shiftRangePct); + const prices = computeLevelPrices(lower, upper, settings.gridLevels, settings.priceTick); + state.gridVersion += 1; + state.anchorPrice = anchor; + state.lowerPrice = lower; + state.upperPrice = upper; + state.levels = buildLevels(prices, settings.direction, anchor); + state.intents = new Map(); + state.awaiting = new Map(); + state.inflight = null; + state.shift = null; + state.exchangeStop = null; + state.prevActiveIds = new Set(); + state.seenOrderIds = new Set(); + state.shiftCandidateSince = null; + state.uncoveredSince = null; +} + +// --------------------------------------------------------------------------- +// planTick:单 tick 决策组合 +// --------------------------------------------------------------------------- + +export function planTick( + state: GridLogicState, + settings: GridLogicSettings, + input: GridTickInput +): GridPlanResult { + const actions: GridPlanAction[] = []; + const events: string[] = []; + let stateChanged = false; + + const snap = processOrderSnapshot(state, input); + events.push(...snap.events); + stateChanged = stateChanged || snap.changed; + + const await_ = resolveAwaiting(state, input); + events.push(...await_.events); + stateChanged = stateChanged || await_.changed; + + // 层①:价格越界。移格开启时优先移格,层①兜移格禁用/失败场景 + const stopReason = checkPriceStop(state, settings, input.price); + if (stopReason) { + if (settings.shiftEnabled && !state.shift) { + beginShift(state, input.price, input.now); + actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price }); + events.push(`价格越界,启动移格: ${stopReason}`); + return { actions, events, stateChanged: true, uncoveredQty: 0 }; + } + actions.push({ kind: "HALT", reason: stopReason }); + return { actions, events, stateChanged, uncoveredQty: 0 }; + } + + // 移格触发(去抖) + if (shouldShift(state, settings, input.price, input.now)) { + beginShift(state, input.price, input.now); + actions.push({ kind: "BEGIN_SHIFT", targetAnchor: input.price }); + events.push(`价格偏离锚定价超阈值,启动移格 (anchor=${state.anchorPrice} → ${input.price})`); + return { actions, events, stateChanged: true, uncoveredQty: 0 }; + } + + // 层②:持仓覆盖审计 + const audit = auditExitCoverage(state, settings, input); + events.push(...audit.events); + if (audit.action) { + actions.push(audit.action); + } + + // 常规挂单 + actions.push(...planOrders(state, settings, input)); + + return { actions, events, stateChanged, uncoveredQty: audit.uncoveredQty }; +} + +// --------------------------------------------------------------------------- +// 三方对账(磁盘 intents ↔ 交易所挂单 ↔ 仓位) +// --------------------------------------------------------------------------- + +export interface ReconcileInput { + activeOrders: OrderView[]; + positionAmt: number; + price: number | null; + now: number; +} + +export interface ReconcileResult { + cancelOrderIds: string[]; + /** 无法归档到任何线的残余仓位(带符号),交由层②/③处置 */ + orphanQty: number; + adopted: number; + events: string[]; +} + +function findLevelByPrice(state: GridLogicState, price: number, halfTick: number): number | null { + for (const level of state.levels) { + if (Math.abs(level.price - price) <= halfTick + PRICE_EPS) return level.index; + } + return null; +} + +export function reconcile( + state: GridLogicState, + settings: GridLogicSettings, + input: ReconcileInput +): ReconcileResult { + const events: string[] = []; + const cancelOrderIds: string[] = []; + const halfTick = settings.priceTick / 2; + const eps = qtyEpsilon(settings); + const oldIntents = state.intents; + const newIntents = new Map(); + + // 先清空各线的订单绑定,凭当前挂单重建 + for (const level of state.levels) { + if (level.phase === "entry_placed") level.phase = "idle"; + if (level.phase === "exit_placed") level.phase = "holding"; + delete level.entryOrderId; + delete level.exitOrderId; + } + + const adoptEntry = (order: OrderView, levelIdx: number, intent: OrderIntentRecord): boolean => { + const level = state.levels[levelIdx]; + if (!level || level.entrySide !== order.side) return false; + if (level.phase !== "idle") return false; + level.phase = "entry_placed"; + level.entryOrderId = order.orderId; + newIntents.set(order.orderId, intent); + return true; + }; + const adoptExit = (order: OrderView, sourceIdx: number, intent: OrderIntentRecord): boolean => { + if (sourceIdx === ORPHAN_LEVEL) { + newIntents.set(order.orderId, intent); + return true; + } + const level = state.levels[sourceIdx]; + if (!level || level.entrySide == null) return false; + const expectedSide: Side = level.entrySide === "BUY" ? "SELL" : "BUY"; + if (expectedSide !== order.side) return false; + if (level.phase !== "holding") return false; + level.phase = "exit_placed"; + level.exitOrderId = order.orderId; + if (level.holdQty <= eps) level.holdQty = Math.max(order.origQty - order.executedQty, 0); + newIntents.set(order.orderId, intent); + return true; + }; + + // 绑定失败的兜底:平仓方向的单收编为孤儿 EXIT(保住覆盖),否则撤销 + const fallbackAdopt = (order: OrderView, remaining: number): void => { + const closingSide: Side | null = + input.positionAmt > eps ? "SELL" : input.positionAmt < -eps ? "BUY" : null; + if (closingSide && order.side === closingSide) { + newIntents.set(order.orderId, { + orderId: order.orderId, + clientOrderId: order.clientOrderId, + intent: "EXIT", + side: order.side, + price: formatPrice(order.price, settings.priceTick), + qty: remaining, + level: ORPHAN_LEVEL, + gridVersion: state.gridVersion, + createdAt: input.now, + }); + events.push(`收编平仓方向挂单为孤儿 EXIT: ${order.side} @ ${order.price}`); + return; + } + cancelOrderIds.push(order.orderId); + events.push(`撤销无法归属的挂单: ${order.side} @ ${order.price}`); + }; + + for (const order of input.activeOrders) { + const remaining = Math.max(order.origQty - order.executedQty, 0); + // 1) orderId 命中磁盘 intents + const known = oldIntents.get(order.orderId); + if (known && known.gridVersion === state.gridVersion) { + const intent: OrderIntentRecord = { ...known, qty: remaining > eps ? remaining : known.qty }; + const ok = + known.intent === "ENTRY" + ? adoptEntry(order, known.level, intent) + : adoptExit(order, known.level, intent); + if (ok) continue; + fallbackAdopt(order, remaining); + continue; + } + // 2) inflight write-ahead 槽位归属 + if ( + state.inflight && + (order.clientOrderId === state.inflight.clientOrderId || + (order.side === state.inflight.side && + Math.abs(order.price - Number(state.inflight.price)) <= halfTick + PRICE_EPS)) + ) { + const rec = state.inflight; + const intent: OrderIntentRecord = { + orderId: order.orderId, + clientOrderId: order.clientOrderId, + intent: rec.intent, + side: rec.side, + price: rec.price, + qty: remaining > eps ? remaining : rec.qty, + level: rec.level, + gridVersion: rec.gridVersion, + createdAt: rec.createdAt, + }; + if (rec.target != null) intent.target = rec.target; + const ok = + rec.intent === "ENTRY" ? adoptEntry(order, rec.level, intent) : adoptExit(order, rec.level, intent); + state.inflight = null; + if (ok) { + events.push(`inflight 归属确认: ${rec.intent} ${rec.side} @ ${rec.price}`); + continue; + } + fallbackAdopt(order, remaining); + continue; + } + // 3) clientOrderId 解析 + const parsed = parseClientOrderId(order.clientOrderId); + if (parsed) { + if (parsed.gridVersion != null && parsed.gridVersion !== state.gridVersion) { + cancelOrderIds.push(order.orderId); + events.push(`撤销过期网格版本挂单: ${order.clientOrderId}`); + continue; + } + const intent: OrderIntentRecord = { + orderId: order.orderId, + clientOrderId: order.clientOrderId, + intent: parsed.intent, + side: order.side, + price: formatPrice(order.price, settings.priceTick), + qty: remaining, + level: parsed.level, + gridVersion: state.gridVersion, + createdAt: input.now, + }; + if (parsed.target != null) intent.target = parsed.target; + const ok = + parsed.intent === "ENTRY" + ? adoptEntry(order, parsed.level, intent) + : adoptExit(order, parsed.level, intent); + if (ok) continue; + fallbackAdopt(order, remaining); + continue; + } + // 4) side+price 对齐档位兜底;歧义优先判 EXIT + const levelIdx = findLevelByPrice(state, order.price, halfTick); + if (levelIdx != null) { + let matched = false; + for (const level of state.levels) { + if (level.exitTarget !== levelIdx) continue; + if (level.entrySide == null) continue; + const expectedSide: Side = level.entrySide === "BUY" ? "SELL" : "BUY"; + if (expectedSide !== order.side) continue; + if (level.phase !== "holding") continue; + const intent: OrderIntentRecord = { + orderId: order.orderId, + clientOrderId: order.clientOrderId, + intent: "EXIT", + side: order.side, + price: formatPrice(order.price, settings.priceTick), + qty: remaining, + level: level.index, + target: levelIdx, + gridVersion: state.gridVersion, + createdAt: input.now, + }; + if (adoptExit(order, level.index, intent)) { + matched = true; + break; + } + } + if (matched) continue; + const level = state.levels[levelIdx]; + if (level && level.entrySide === order.side && level.phase === "idle") { + const intent: OrderIntentRecord = { + orderId: order.orderId, + clientOrderId: order.clientOrderId, + intent: "ENTRY", + side: order.side, + price: formatPrice(order.price, settings.priceTick), + qty: remaining, + level: levelIdx, + gridVersion: state.gridVersion, + createdAt: input.now, + }; + if (adoptEntry(order, levelIdx, intent)) continue; + } + } + // 5) 三无匹配 + fallbackAdopt(order, remaining); + } + + state.inflight = null; + state.intents = newIntents; + state.awaiting = new Map(); + state.prevActiveIds = new Set(newIntents.keys()); + state.seenOrderIds = new Set(newIntents.keys()); + + // 仓位对账:diff = 实际净仓 − 各线持仓合计,归档到最近线(每线 ≤ orderSize) + let expectedNet = 0; + for (const level of state.levels) { + if (level.phase !== "holding" && level.phase !== "exit_placed") continue; + if (level.entrySide === "BUY") expectedNet += level.holdQty; + else if (level.entrySide === "SELL") expectedNet -= level.holdQty; + } + let diff = input.positionAmt - expectedNet; + const refPrice = input.price ?? state.anchorPrice; + + const releaseHolds = (entrySide: Side, amount: number): number => { + // 仓位少于预期:优先释放 exit 目标价离现价最近的线(最可能已被成交) + let remaining = amount; + const held = state.levels + .filter((l) => (l.phase === "holding" || l.phase === "exit_placed") && l.entrySide === entrySide) + .sort((a, b) => { + const pa = a.exitTarget != null ? state.levels[a.exitTarget]!.price : a.price; + const pb = b.exitTarget != null ? state.levels[b.exitTarget]!.price : b.price; + return Math.abs(pa - refPrice) - Math.abs(pb - refPrice); + }); + for (const level of held) { + if (remaining <= eps) break; + const take = Math.min(level.holdQty, remaining); + level.holdQty -= take; + remaining -= take; + if (level.holdQty <= eps) { + if (level.exitOrderId) { + cancelOrderIds.push(level.exitOrderId); + newIntents.delete(level.exitOrderId); + } + level.phase = "idle"; + level.holdQty = 0; + delete level.exitOrderId; + } + } + return remaining; + }; + const assignHolds = (entrySide: Side, amount: number): number => { + let remaining = amount; + const idle = state.levels + .filter((l) => l.phase === "idle" && l.entrySide === entrySide) + .sort((a, b) => Math.abs(a.price - refPrice) - Math.abs(b.price - refPrice)); + for (const level of idle) { + if (remaining <= eps) break; + const take = Math.min(settings.orderSize, remaining); + level.phase = "holding"; + level.holdQty = take; + remaining -= take; + } + return remaining; + }; + + if (diff > eps) { + let remaining = releaseHolds("SELL", diff); + remaining = assignHolds("BUY", remaining); + diff = remaining; + } else if (diff < -eps) { + let remaining = releaseHolds("BUY", -diff); + remaining = assignHolds("SELL", remaining); + diff = -remaining; + } else { + diff = 0; + } + if (Math.abs(diff) > eps) { + events.push(`对账残余孤儿仓位: ${diff.toFixed(6)}`); + // 立即进入层②处置(跳过宽限期) + state.uncoveredSince = input.now - 86_400_000; + } + + return { cancelOrderIds, orphanQty: Math.abs(diff) > eps ? diff : 0, adopted: newIntents.size, events }; +} + +// --------------------------------------------------------------------------- +// 持久化转换 +// --------------------------------------------------------------------------- + +export interface StateMeta { + symbol: string; + exchangeId: string; + direction: GridTradeMode; + orderSize: number; + maxPositionSize: number; + gridLevels: number; + gridMode: string; +} + +export function toStored(state: GridLogicState, meta: StateMeta, now: number): StoredGridStateV2 { + const levels: Record = {}; + for (const level of state.levels) { + if (level.phase === "idle") continue; + const entry: StoredLevelV2 = { + phase: level.phase, + exitTarget: level.exitTarget, + holdQty: level.holdQty, + }; + if (level.entryOrderId) entry.entryOrderId = level.entryOrderId; + if (level.exitOrderId) entry.exitOrderId = level.exitOrderId; + levels[String(level.index)] = entry; + } + return { + schemaVersion: 2, + symbol: meta.symbol, + exchangeId: meta.exchangeId, + gridVersion: state.gridVersion, + anchorPrice: state.anchorPrice, + lowerPrice: state.lowerPrice, + upperPrice: state.upperPrice, + gridLevels: meta.gridLevels, + orderSize: meta.orderSize, + maxPositionSize: meta.maxPositionSize, + direction: meta.direction, + gridMode: meta.gridMode, + levels, + intents: Array.from(state.intents.values()), + inflight: state.inflight, + shift: state.shift, + exchangeStop: state.exchangeStop, + updatedAt: now, + }; +} + +/** config 指纹一致才允许恢复;边界以磁盘为准(移格后与 env 不同) */ +export function isCompatibleStoredState(stored: StoredGridStateV2, meta: StateMeta): boolean { + if (stored.symbol !== meta.symbol) return false; + if (stored.exchangeId && meta.exchangeId && stored.exchangeId !== meta.exchangeId) return false; + const storedDirection = stored.direction === "both" ? "neutral" : stored.direction; + if (storedDirection !== meta.direction) return false; + if (Math.abs(stored.orderSize - meta.orderSize) > 1e-12) return false; + if (stored.gridLevels !== meta.gridLevels) return false; + if (stored.gridMode && stored.gridMode !== meta.gridMode) return false; + return true; +} + +export function fromStored( + stored: StoredGridStateV2, + settings: GridLogicSettings, + fallbackAnchor: number +): GridLogicState { + const lower = stored.lowerPrice > 0 ? stored.lowerPrice : settings.lowerPrice; + const upper = stored.upperPrice > lower ? stored.upperPrice : settings.upperPrice; + const anchor = clampPrice( + stored.anchorPrice != null && stored.anchorPrice > 0 ? stored.anchorPrice : fallbackAnchor, + lower, + upper + ); + const prices = computeLevelPrices(lower, upper, settings.gridLevels, settings.priceTick); + const state: GridLogicState = { + gridVersion: stored.gridVersion > 0 ? stored.gridVersion : 1, + anchorPrice: anchor, + lowerPrice: lower, + upperPrice: upper, + levels: buildLevels(prices, settings.direction, anchor), + intents: new Map(), + awaiting: new Map(), + inflight: stored.inflight ?? null, + shift: stored.shift ?? null, + exchangeStop: stored.exchangeStop ?? null, + prevActiveIds: new Set(), + seenOrderIds: new Set(), + shiftCandidateSince: null, + uncoveredSince: null, + }; + for (const [key, info] of Object.entries(stored.levels ?? {})) { + const idx = Number(key); + const level = state.levels[idx]; + if (!level || !Number.isFinite(idx)) continue; + level.phase = info.phase; + level.holdQty = Number.isFinite(info.holdQty) ? info.holdQty : 0; + if (info.entryOrderId) level.entryOrderId = info.entryOrderId; + if (info.exitOrderId) level.exitOrderId = info.exitOrderId; + } + for (const intent of stored.intents ?? []) { + if (!intent || !intent.orderId) continue; + state.intents.set(String(intent.orderId), { ...intent, orderId: String(intent.orderId) }); + } + state.prevActiveIds = new Set(state.intents.keys()); + return state; +} diff --git a/src/ui/GridApp.tsx b/src/ui/GridApp.tsx index 6ebfdc7..32bb9a6 100644 --- a/src/ui/GridApp.tsx +++ b/src/ui/GridApp.tsx @@ -90,15 +90,17 @@ export function GridApp({ onExit }: GridAppProps) { { key: "level", header: "#", align: "right", minWidth: 3 }, { key: "price", header: "Price", align: "right", minWidth: 10 }, { key: "side", header: "Side", minWidth: 4 }, - { key: "active", header: "Active", minWidth: 6 }, + { key: "state", header: "State", minWidth: 11 }, { key: "hasOrder", header: "Order", minWidth: 5 }, + { key: "hold", header: "Hold", align: "right", minWidth: 8 }, ]; const gridRows = snapshot.gridLines.map((line) => ({ level: line.level, price: formatNumber(line.price, 4), side: line.side, - active: line.active ? "yes" : "no", + state: line.state, hasOrder: line.hasOrder ? "yes" : "no", + hold: line.holdQty > 0 ? formatNumber(line.holdQty, 4) : "-", })); const desiredColumns: TableColumn[] = [ @@ -141,6 +143,23 @@ export function GridApp({ onExit }: GridAppProps) { count: snapshot.gridLines.length, })} + + {t("grid.anchorLine", { + anchor: formatNumber(snapshot.anchorPrice, 4), + version: snapshot.gridVersion, + })} + {snapshot.shiftPhase ? ( + | {t("grid.shiftState", { phase: snapshot.shiftPhase })} + ) : null} + + 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"), + })} + {t("grid.dataStatus")} {feedEntries.map((entry, index) => ( diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts index d3bb58d..3a3acfd 100644 --- a/tests/grid-engine.test.ts +++ b/tests/grid-engine.test.ts @@ -1,35 +1,48 @@ -import { describe, expect, it } from "vitest"; -import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { promises as fs } from "fs"; +import os from "os"; +import path from "path"; +import type { ConnectionEventListener, ExchangeAdapter } from "../src/exchanges/adapter"; import type { AccountSnapshot, - Depth, + CreateOrderParams, Order, Ticker, - CreateOrderParams, } from "../src/exchanges/types"; import type { GridConfig } from "../src/config"; import { GridEngine } from "../src/strategy/grid-engine"; +import { saveGridState } from "../src/strategy/common/grid-storage"; +import { createInitialState, toStored, type GridLogicSettings } from "../src/strategy/grid-logic"; let orderCounter = 0; -class StubAdapter implements ExchangeAdapter { +class FakeAdapter implements ExchangeAdapter { id = "aster"; + triggerOrders = false; private accountHandler: ((snapshot: AccountSnapshot) => void) | null = null; private orderHandler: ((orders: Order[]) => void) | null = null; - private depthHandler: ((depth: Depth) => void) | null = null; private tickerHandler: ((ticker: Ticker) => void) | null = null; - private currentOrders: Order[] = []; + private depthHandler: ((depth: any) => void) | null = null; + private connectionListeners: ConnectionEventListener[] = []; + private lastAccount: AccountSnapshot | null = null; - public createdOrders: CreateOrderParams[] = []; - public marketOrders: CreateOrderParams[] = []; - public cancelAllCount = 0; - public cancelledOrders: Array = []; + currentOrders: Order[] = []; + createdOrders: CreateOrderParams[] = []; + limitOrders: CreateOrderParams[] = []; + marketOrders: CreateOrderParams[] = []; + stopOrders: CreateOrderParams[] = []; + cancelledIds: string[] = []; + cancelAllCount = 0; supportsTrailingStops(): boolean { return false; } + supportsTriggerOrders(): boolean { + return this.triggerOrders; + } + watchAccount(cb: (snapshot: AccountSnapshot) => void): void { this.accountHandler = cb; } @@ -38,7 +51,7 @@ class StubAdapter implements ExchangeAdapter { this.orderHandler = cb; } - watchDepth(_symbol: string, cb: (depth: Depth) => void): void { + watchDepth(_symbol: string, cb: (depth: any) => void): void { this.depthHandler = cb; } @@ -47,18 +60,35 @@ class StubAdapter implements ExchangeAdapter { } watchKlines(): void { - // not used in tests + // not used + } + + onConnectionEvent(listener: ConnectionEventListener): void { + this.connectionListeners.push(listener); + } + + async queryOpenOrders(): Promise { + return [...this.currentOrders]; + } + + async queryAccountSnapshot(): Promise { + return this.lastAccount; + } + + emitConnection(event: "disconnected" | "reconnected", symbol = "BTCUSDT"): void { + for (const listener of this.connectionListeners) listener(event, symbol); } emitAccount(snapshot: AccountSnapshot): void { + this.lastAccount = snapshot; this.accountHandler?.(snapshot); } - emitOrders(orders: Order[]): void { - this.orderHandler?.(orders); + emitOrders(orders?: Order[]): void { + this.orderHandler?.(orders ?? [...this.currentOrders]); } - emitDepth(depth: Depth): void { + emitDepth(depth: any): void { this.depthHandler?.(depth); } @@ -67,8 +97,8 @@ class StubAdapter implements ExchangeAdapter { } async createOrder(params: CreateOrderParams): Promise { - orderCounter++; - const orderId = params.clientOrderId ?? `stub-${orderCounter}`; + orderCounter += 1; + const orderId = `srv-${orderCounter}`; const order: Order = { orderId, clientOrderId: params.clientOrderId ?? orderId, @@ -79,643 +109,682 @@ class StubAdapter implements ExchangeAdapter { price: Number(params.price ?? 0).toString(), origQty: Number(params.quantity ?? 0).toString(), executedQty: "0", - stopPrice: "0", - time: Date.now(), - updateTime: Date.now(), + stopPrice: Number(params.stopPrice ?? 0).toString(), + time: 0, + updateTime: 0, reduceOnly: params.reduceOnly === "true", - closePosition: false, + closePosition: params.closePosition === "true", }; this.createdOrders.push(params); if (params.type === "MARKET") { this.marketOrders.push(params); - this.orderHandler?.([]); - } else { + this.emitOrders(); + } else if (params.type === "STOP_MARKET") { + this.stopOrders.push(params); this.currentOrders.push(order); - this.orderHandler?.([...this.currentOrders]); + this.emitOrders(); + } else { + this.limitOrders.push(params); + this.currentOrders.push(order); + this.emitOrders(); } return order; } async cancelOrder(params: { symbol: string; orderId: number | string }): Promise { - this.cancelledOrders.push(params.orderId); - this.currentOrders = this.currentOrders.filter(o => String(o.orderId) !== String(params.orderId)); + this.cancelledIds.push(String(params.orderId)); + this.currentOrders = this.currentOrders.filter((o) => String(o.orderId) !== String(params.orderId)); + this.emitOrders(); } async cancelOrders(params: { symbol: string; orderIdList: Array }): Promise { - this.cancelledOrders.push(...params.orderIdList); - const idSet = new Set(params.orderIdList.map(String)); - this.currentOrders = this.currentOrders.filter(o => !idSet.has(String(o.orderId))); + const ids = new Set(params.orderIdList.map(String)); + this.cancelledIds.push(...params.orderIdList.map(String)); + this.currentOrders = this.currentOrders.filter((o) => !ids.has(String(o.orderId))); + this.emitOrders(); } async cancelAllOrders(): Promise { this.cancelAllCount += 1; this.currentOrders = []; - this.orderHandler?.([]); + this.emitOrders(); } - clearCurrentOrders(): void { - this.currentOrders = []; + /** 模拟成交:先在订单流里出现 FILLED 终态,再从活跃单移除 */ + fillOrder(orderId: string): Order | null { + const order = this.currentOrders.find((o) => String(o.orderId) === orderId); + if (!order) return null; + const filled: Order = { ...order, status: "FILLED", executedQty: order.origQty }; + this.currentOrders = this.currentOrders.filter((o) => String(o.orderId) !== orderId); + this.emitOrders([...this.currentOrders, filled]); + return filled; } - getCurrentOrders(): Order[] { - return [...this.currentOrders]; + findOrders(predicate: (o: Order) => boolean): Order[] { + return this.currentOrders.filter(predicate); } } -function createAccountSnapshot(symbol: string, positionAmt: number): AccountSnapshot { +function accountSnapshot( + symbol: string, + positionAmt: number, + entryPrice = 150, + markPrice?: number +): AccountSnapshot { return { canTrade: true, canDeposit: true, canWithdraw: true, - updateTime: Date.now(), - totalWalletBalance: "0", + updateTime: 0, + totalWalletBalance: "1000", totalUnrealizedProfit: "0", positions: [ { symbol, positionAmt: positionAmt.toString(), - entryPrice: "150", + entryPrice: entryPrice.toString(), unrealizedProfit: "0", positionSide: "BOTH", - updateTime: Date.now(), + updateTime: 0, + ...(markPrice != null ? { markPrice: markPrice.toString() } : {}), }, ], assets: [], } as unknown as AccountSnapshot; } -describe("GridEngine", () => { - const baseConfig: GridConfig = { +function ticker(symbol: string, price: number): Ticker { + return { + symbol, + lastPrice: price.toString(), + openPrice: price.toString(), + highPrice: price.toString(), + lowPrice: price.toString(), + volume: "0", + quoteVolume: "0", + }; +} + +function makeConfig(overrides: Partial = {}): GridConfig { + return { symbol: "BTCUSDT", lowerPrice: 100, upperPrice: 200, - gridLevels: 3, + gridLevels: 5, orderSize: 0.1, - maxPositionSize: 0.2, + maxPositionSize: 0.4, refreshIntervalMs: 10, - maxLogEntries: 50, + maxLogEntries: 200, priceTick: 0.1, - qtyStep: 0.01, + qtyStep: 0.001, direction: "both", stopLossPct: 0.01, restartTriggerPct: 0.01, autoRestart: true, gridMode: "geometric", maxCloseSlippagePct: 0.05, + gridShiftEnabled: false, + gridShiftTriggerPct: 0.05, + gridShiftRangePct: 0.05, + gridShiftConfirmMs: 3000, + useReduceOnlyForExit: false, + exchangeStopEnabled: false, + reconcileIntervalMs: 30_000, + uncoveredGraceMs: 5000, + ...overrides, }; +} - it("creates geometric desired orders when running in both directions", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); +const clock = { t: 1_000_000 }; - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} - // use internal syncGrid to generate orders without waiting for timers - const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; price: string }>; - expect(desired).toHaveLength(3); - const buyOrders = desired.filter((order) => order.side === "BUY"); - const sellOrders = desired.filter((order) => order.side === "SELL"); - expect(buyOrders).toHaveLength(2); - expect(sellOrders).toHaveLength(1); - expect(Number(buyOrders[0]?.price)).toBeCloseTo(141.4, 1); - expect(Number(buyOrders[1]?.price)).toBeCloseTo(100, 6); - expect(Number(sellOrders[0]?.price)).toBeCloseTo(200, 6); +function releaseLimitLock(engine: GridEngine): void { + const anyEngine = engine as any; + for (const type of ["LIMIT", "STOP_MARKET"]) { + anyEngine.locks[type] = false; + anyEngine.pendings[type] = null; + if (anyEngine.timers[type]) { + clearTimeout(anyEngine.timers[type]); + anyEngine.timers[type] = null; + } + } +} - engine.stop(); +async function drive(engine: GridEngine, ticks: number, stepMs = 100): Promise { + for (let i = 0; i < ticks; i += 1) { + clock.t += stepMs; + await (engine as any).tick(); + releaseLimitLock(engine); + await settle(); + } +} + +function bootFeeds( + adapter: FakeAdapter, + config: GridConfig, + options: { positionAmt?: number; entryPrice?: number; markPrice?: number; price?: number } = {} +): void { + adapter.emitAccount( + accountSnapshot( + config.symbol, + options.positionAmt ?? 0, + options.entryPrice ?? 150, + options.markPrice + ) + ); + adapter.emitTicker(ticker(config.symbol, options.price ?? 150)); + adapter.emitOrders(); +} + +async function bootEngine( + config: GridConfig, + adapter: FakeAdapter, + options: { positionAmt?: number; entryPrice?: number; markPrice?: number; price?: number; skipPersistence?: boolean } = {} +): Promise { + const engine = new GridEngine(config, adapter, { + now: () => clock.t, + skipPersistence: options.skipPersistence ?? true, }); + bootFeeds(adapter, config, options); + await settle(); + await drive(engine, 1); + return engine; +} - it("limits sell orders for long-only direction when no position is available", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0, skipPersistence: true }); +let tmpDir: string | null = null; +let prevDataDir: string | undefined; - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); +beforeEach(() => { + clock.t = 1_000_000; + prevDataDir = process.env.GRID_DATA_DIR; +}); - const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; reduceOnly: boolean }>; - const sells = desired.filter((order) => order.side === "SELL"); - const buys = desired.filter((order) => order.side === "BUY"); +afterEach(async () => { + if (prevDataDir == null) delete process.env.GRID_DATA_DIR; + else process.env.GRID_DATA_DIR = prevDataDir; + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } +}); - expect(buys.length).toBeGreaterThan(0); - expect(sells).toHaveLength(0); +async function useTmpStorage(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "grid-engine-test-")); + process.env.GRID_DATA_DIR = tmpDir; + return tmpDir; +} - engine.stop(); - }); +// --------------------------------------------------------------------------- +// 三模式建格 +// --------------------------------------------------------------------------- - it("does not repopulate the same buy level until exposure is released", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); +describe("GridEngine modes", () => { + it("neutral splits entries at the anchor: BUY below, SELL above", async () => { + const adapter = new FakeAdapter(); + const engine = await bootEngine(makeConfig(), adapter); + await drive(engine, 8); - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - - const desiredInitial = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>; - const nearestBuy = desiredInitial.find((order) => order.side === "BUY"); - expect(nearestBuy).toBeTruthy(); - const targetLevel = nearestBuy!.level; - - (engine as any).longExposure.set(targetLevel, baseConfig.orderSize); - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); - - const desiredAfterFill = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>; - expect(desiredAfterFill.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(false); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - const desiredAfterExit = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string }>; - expect(desiredAfterExit.some((order) => order.level === targetLevel && order.side === "BUY")).toBe(true); - - engine.stop(); - }); - - it("keeps level side assignments stable regardless of price", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - - const desiredHigh = (engine as any).computeDesiredOrders(2.45) as Array<{ level: number; side: string }>; - expect(desiredHigh.every((order) => { - const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2); - return isBuyLevel ? order.side === "BUY" : order.side === "SELL"; - })).toBe(true); - - const desiredLow = (engine as any).computeDesiredOrders(1.55) as Array<{ level: number; side: string }>; - expect(desiredLow.every((order) => { - const isBuyLevel = order.level <= Math.floor((baseConfig.gridLevels - 1) / 2); - return isBuyLevel ? order.side === "BUY" : order.side === "SELL"; - })).toBe(true); - - engine.stop(); - }); - - it("limits active sell orders by remaining short headroom", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - - const desiredFull = (engine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>; - const sellCountFull = desiredFull.filter((order) => order.side === "SELL").length; - expect(sellCountFull).toBeGreaterThan(0); - - const limitedHeadroomConfig = { ...baseConfig, maxPositionSize: baseConfig.orderSize * 2 }; - const limitedEngine = new GridEngine(limitedHeadroomConfig, adapter as any, { now: () => 0, skipPersistence: true }); - (limitedEngine as any).shortExposure.set(12, baseConfig.orderSize * 2); - - const desiredLimited = (limitedEngine as any).computeDesiredOrders(2.1) as Array<{ level: number; side: string }>; - const sellCountLimited = desiredLimited.filter((order) => order.side === "SELL").length; - expect(sellCountLimited).toBeLessThanOrEqual(1); - - engine.stop(); - limitedEngine.stop(); - }); - - it("places reduce-only orders to close existing exposures", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); - adapter.emitOrders([]); - - const buyLevel = (engine as any).buyLevelIndices.slice(-1)[0]; - (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); - - const desired = (engine as any).computeDesiredOrders(2.05) as Array<{ - level: number; - side: string; - reduceOnly: boolean; - amount: number; - }>; - - const closeOrder = desired.find((order) => order.reduceOnly && order.side === "SELL"); - expect(closeOrder).toBeTruthy(); - expect(closeOrder!.amount).toBeCloseTo(baseConfig.orderSize); - - engine.stop(); - }); - - it("restores exposures from existing reduce-only orders on restart", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize * 2)); - - const reduceOrder: Order = { - orderId: "existing-reduce", - clientOrderId: "existing-reduce", - symbol: baseConfig.symbol, - side: "SELL", - type: "LIMIT", - status: "NEW", - price: baseConfig.upperPrice.toFixed(1), - origQty: (baseConfig.orderSize * 2).toString(), - executedQty: "0", - stopPrice: "0", - time: Date.now(), - updateTime: Date.now(), - reduceOnly: true, - closePosition: false, - }; - - adapter.emitOrders([reduceOrder]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); - - await (engine as any).syncGrid(150); - - const longExposure: Map = (engine as any).longExposure; - const buyIndices: number[] = (engine as any).buyLevelIndices; - - const totalExposure = [...longExposure.values()].reduce((acc, qty) => acc + qty, 0); - expect(totalExposure).toBeCloseTo(baseConfig.orderSize * 2, 6); - expect(longExposure.get(buyIndices.slice(-1)[0]!)).toBeCloseTo(baseConfig.orderSize, 6); - expect(longExposure.get(buyIndices[0]!)).toBeCloseTo(baseConfig.orderSize, 6); + expect(adapter.limitOrders.length).toBeGreaterThan(1); + for (const params of adapter.limitOrders) { + if (params.side === "BUY") expect(Number(params.price)).toBeLessThan(150); + else expect(Number(params.price)).toBeGreaterThan(150); + } + expect(adapter.limitOrders.some((p) => p.side === "BUY")).toBe(true); + expect(adapter.limitOrders.some((p) => p.side === "SELL")).toBe(true); const snapshot = engine.getSnapshot(); - const reduceDesired = snapshot.desiredOrders.find( - (order) => order.reduceOnly && order.side === "SELL" - ); - expect(reduceDesired).toBeTruthy(); - expect(reduceDesired!.amount).toBeCloseTo(baseConfig.orderSize * 2, 6); - expect(Number(reduceDesired!.price)).toBeCloseTo(baseConfig.upperPrice, 6); - // New engine cancels unrecognized orders (no grid- prefix) during recovery; - // legacy syncGrid still picks up exposure from position regardless. - + expect(snapshot.gridVersion).toBe(1); + expect(snapshot.anchorPrice).toBeCloseTo(150, 6); + expect(snapshot.direction).toBe("both"); engine.stop(); }); - it("halts the grid and closes positions when stop loss triggers", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0.2)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); - - (engine as any).stopReason = "test stop"; - await (engine as any).haltGrid(90); - - expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1); - expect(adapter.marketOrders).toHaveLength(1); - expect(engine.getSnapshot().running).toBe(false); - + it("long mode only places BUY entries", async () => { + const adapter = new FakeAdapter(); + const engine = await bootEngine(makeConfig({ direction: "long" }), adapter); + await drive(engine, 8); + expect(adapter.limitOrders.length).toBeGreaterThan(0); + expect(adapter.limitOrders.every((p) => p.side === "BUY")).toBe(true); engine.stop(); }); - // ----------------------------------------------------------------------- - // New tests for refactored level-state tracking & clientOrderId system - // ----------------------------------------------------------------------- - - it("encodes and decodes ENTRY clientOrderId correctly", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 1000, skipPersistence: true }); - - const makeId = (engine as any).__proto__.constructor; // access via module scope - // Access the private function through the engine's internal methods - // We test indirectly by placing an order and checking its clientOrderId - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); - - // Force recovery to complete - (engine as any).recoveryDone = true; + it("short mode only places SELL entries", async () => { + const adapter = new FakeAdapter(); + const engine = await bootEngine(makeConfig({ direction: "short" }), adapter); + await drive(engine, 8); + expect(adapter.limitOrders.length).toBeGreaterThan(0); + expect(adapter.limitOrders.every((p) => p.side === "SELL")).toBe(true); + engine.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 生命周期:ENTRY 成交 → EXIT → 释放 +// --------------------------------------------------------------------------- + +describe("GridEngine lifecycle", () => { + it("fills ENTRY, places EXIT at adjacent line, releases level after EXIT fills", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter); + await drive(engine, 4); + + // 找到 141.4 的 BUY ENTRY(最近的买线)并成交 + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + expect(entry).toBeTruthy(); + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engine, 3); + + // 相邻线 168.2 出现 SELL EXIT + const exitParams = adapter.limitOrders.filter( + (p) => + p.side === "SELL" && + Math.abs(Number(p.price) - 168.2) < 0.2 && + p.clientOrderId?.includes("-X-") + ); + expect(exitParams.length).toBe(1); + + const line = engine.getSnapshot().gridLines.find((l) => Math.abs(l.price - 141.4) < 0.2); + expect(line?.state).toBe("exit_placed"); + + // EXIT 成交 → 线释放 → 重新可开仓 + const exitOrder = adapter.findOrders( + (o) => + o.side === "SELL" && + Math.abs(Number(o.price) - 168.2) < 0.2 && + o.clientOrderId.includes("-X-") + )[0]; + adapter.fillOrder(String(exitOrder!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0, 0)); + await drive(engine, 3); + + const lineAfter = engine.getSnapshot().gridLines.find((l) => Math.abs(l.price - 141.4) < 0.2); + expect(lineAfter?.state === "idle" || lineAfter?.state === "entry_placed").toBe(true); + await drive(engine, 4); + const buyEntriesAtLevel = adapter.limitOrders.filter( + (p) => p.side === "BUY" && Math.abs(Number(p.price) - 141.4) < 0.2 + ); + expect(buyEntriesAtLevel.length).toBe(2); // 首次 + 释放后重挂 + engine.stop(); + }); - // Trigger syncGridSimple which should place orders with clientOrderIds - // We'll interact through the desired orders and order placement instead - - const desired = (engine as any).computeDesiredOrders(150) as Array<{ intent: string }>; - // All orders from computeDesiredOrders should have intent set - for (const d of desired) { - expect(d.intent).toBeDefined(); - expect(["ENTRY", "EXIT"]).toContain(d.intent); + it("does not duplicate ENTRY while the level is holding across price crossings", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter); + await drive(engine, 4); + + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engine, 2); + + // 价格在该线两侧来回穿越 + for (const price of [130, 155, 130, 155, 130]) { + adapter.emitTicker(ticker(config.symbol, price)); + await drive(engine, 2); } + const buyEntriesAtLevel = adapter.limitOrders.filter( + (p) => p.side === "BUY" && Math.abs(Number(p.price) - 141.4) < 0.2 + ); + expect(buyEntriesAtLevel.length).toBe(1); + const exitsAtTarget = adapter.limitOrders.filter( + (p) => + p.side === "SELL" && + Math.abs(Number(p.price) - 168.2) < 0.2 && + p.clientOrderId?.includes("-X-") + ); + expect(exitsAtTarget.length).toBe(1); + const entriesAtTarget = adapter.limitOrders.filter( + (p) => + p.side === "SELL" && + Math.abs(Number(p.price) - 168.2) < 0.2 && + p.clientOrderId?.includes("-E-") + ); + expect(entriesAtTarget.length).toBe(1); // 线 3 自身的空头开仓单可并存但不重复 engine.stop(); }); - it("marks level as filled when ENTRY disappears as filled", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); + it("EXIT orders honor the reduce-only switch", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig({ useReduceOnlyForExit: true }); + const engine = await bootEngine(config, adapter); + await drive(engine, 4); + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engine, 3); + const exitParams = adapter.limitOrders.find((p) => p.clientOrderId?.includes("-X-")); + expect(exitParams).toBeTruthy(); + expect(exitParams!.reduceOnly).toBe("true"); + engine.stop(); + }); - (engine as any).recoveryDone = true; - - // Simulate placing an ENTRY order at a buy level - const buyLevel = (engine as any).buyLevelIndices[0] as number; - const levelPrice = (engine as any).gridLevels[buyLevel]; - const priceStr = (engine as any).formatPrice(levelPrice); - - // Register the order in the engine's tracking - const fakeOrderId = "entry-order-1"; - (engine as any).orderIntentById.set(fakeOrderId, { + it("EXIT orders default to no reduce-only flag", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter); + await drive(engine, 4); + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engine, 3); + const exitParams = adapter.limitOrders.find((p) => p.clientOrderId?.includes("-X-")); + expect(exitParams).toBeTruthy(); + expect(exitParams!.reduceOnly).not.toBe("true"); + engine.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 恢复与对账 +// --------------------------------------------------------------------------- + +describe("GridEngine recovery", () => { + it("cancels stranger orders during startup reconcile", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + // 预置一张与任何档位不对齐的陌生单 + adapter.currentOrders.push({ + orderId: "stranger-1", + clientOrderId: "someone-else", + symbol: config.symbol, side: "BUY", - price: priceStr, - level: buyLevel, - intent: "ENTRY", - }); - - // First sync: the order is active → record it in prevActiveIds - const activeOrder: Order = { - orderId: fakeOrderId, - clientOrderId: fakeOrderId, - symbol: baseConfig.symbol, - side: "BUY", type: "LIMIT", status: "NEW", - price: priceStr, - origQty: baseConfig.orderSize.toString(), + price: "133.3", + origQty: "0.1", executedQty: "0", stopPrice: "0", - time: Date.now(), - updateTime: Date.now(), + time: 0, + updateTime: 0, reduceOnly: false, closePosition: false, - }; - - // Set engine's openOrders to include the active order - (engine as any).openOrders = [activeOrder]; - // Run syncGridSimple so prevActiveIds gets populated - await (engine as any).syncGridSimple(150); - - // Verify level starts as idle - expect((engine as any).levelStates.get(buyLevel)).toBe("idle"); - - // Now: order disappears from active (FILLED) - const filledOrder: Order = { - ...activeOrder, - status: "FILLED", - executedQty: baseConfig.orderSize.toString(), - }; - - // Update engine openOrders: the order is now FILLED (not active) - // Also include a fake EXIT order so exit-first logic doesn't short-circuit - const fakeExitOrder: Order = { - orderId: "fake-exit", - clientOrderId: "grid-X-0-2-abc", - symbol: baseConfig.symbol, - side: "SELL", - type: "LIMIT", - status: "NEW", - price: "200.0", - origQty: baseConfig.orderSize.toString(), - executedQty: "0", - stopPrice: "0", - time: Date.now(), - updateTime: Date.now(), - reduceOnly: false, - closePosition: false, - }; - (engine as any).orderIntentById.set("fake-exit", { - side: "SELL", - price: "200.0", - level: 2, - intent: "EXIT", - sourceLevel: 0, }); + const engine = await bootEngine(config, adapter); + await drive(engine, 2); + expect(adapter.cancelledIds).toContain("stranger-1"); + engine.stop(); + }); - (engine as any).openOrders = [filledOrder, fakeExitOrder]; - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); + it("restores state from disk and does not duplicate orders after restart", async () => { + await useTmpStorage(); + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engineA = await bootEngine(config, adapter, { skipPersistence: false }); + await drive(engineA, 4); + + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + expect(entry).toBeTruthy(); + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engineA, 3); + engineA.stop(); + + const exitCountBefore = adapter.limitOrders.filter((p) => p.clientOrderId?.includes("-X-")).length; + expect(exitCountBefore).toBe(1); + const exitOrder = adapter.findOrders((o) => o.clientOrderId.includes("-X-"))[0]; + expect(exitOrder).toBeTruthy(); + + // “重启”:新引擎实例,同一存储目录与交易所现场 + const engineB = new GridEngine(config, adapter, { now: () => clock.t, skipPersistence: false }); + bootFeeds(adapter, config, { positionAmt: 0.1, entryPrice: 141.4 }); + await settle(); + await drive(engineB, 3); + + // 已有 EXIT 挂单被继承:没有撤销、没有重复 EXIT + expect(adapter.cancelledIds).toHaveLength(0); + const exitCount = adapter.limitOrders.filter((p) => p.clientOrderId?.includes("-X-")).length; + expect(exitCount).toBe(1); + const heldLine = engineB.getSnapshot().gridLines.find((l) => Math.abs(l.price - 141.4) < 0.2); + expect(heldLine?.state).toBe("exit_placed"); + // 已持仓线不再重复挂 ENTRY + const buyAtHeld = adapter.limitOrders.filter( + (p) => p.side === "BUY" && Math.abs(Number(p.price) - 141.4) < 0.2 + ); + expect(buyAtHeld.length).toBe(1); + engineB.stop(); + }); - // Trigger tick to process disappearance - await (engine as any).syncGridSimple(150); - - // Level should now be "filled" - expect((engine as any).levelStates.get(buyLevel)).toBe("filled"); - + it("freezes on disconnect and reconciles via REST after reconnect", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter); + await drive(engine, 3); + const placedBefore = adapter.limitOrders.length; + expect(placedBefore).toBeGreaterThan(0); + + // 断连:冻结,不再下新单 + adapter.emitConnection("disconnected"); + await drive(engine, 4); + expect(adapter.limitOrders.length).toBe(placedBefore); + + // 断连期间某 ENTRY 在服务端成交(订单流不可用,直接改现场) + const entry = adapter.currentOrders.find((o) => o.side === "BUY" && Number(o.price) === 141.4); + expect(entry).toBeTruthy(); + adapter.currentOrders = adapter.currentOrders.filter((o) => o.orderId !== entry!.orderId); + (adapter as any).lastAccount = accountSnapshot(config.symbol, 0.1, 141.4); + + // 重连:REST 对账把成交归位到线 → 补挂 EXIT + adapter.emitConnection("reconnected"); + await drive(engine, 4); + const heldLine = engine.getSnapshot().gridLines.find((l) => Math.abs(l.price - 141.4) < 0.2); + expect(heldLine?.state === "holding" || heldLine?.state === "exit_placed").toBe(true); + const exits = adapter.limitOrders.filter((p) => p.side === "SELL" && p.clientOrderId?.includes("-X-")); + expect(exits.length).toBeGreaterThanOrEqual(1); + engine.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 多重止损 +// --------------------------------------------------------------------------- + +describe("GridEngine stop-loss layers", () => { + it("layer 1: halts, cancels and closes when price breaks the band", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter, { positionAmt: 0.2, entryPrice: 150 }); + await drive(engine, 2); + + adapter.emitTicker(ticker(config.symbol, 95)); + await drive(engine, 2); + + expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1); + expect(adapter.marketOrders.length).toBe(1); + expect(adapter.marketOrders[0]!.side).toBe("SELL"); + expect(engine.getSnapshot().running).toBe(false); engine.stop(); }); - it("refuses new ENTRY at a level that is already filled", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", + it("layer 1: slippage guard defers the close and keeps retrying", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + const engine = await bootEngine(config, adapter, { + positionAmt: 0.2, + entryPrice: 150, + markPrice: 95, }); - - (engine as any).recoveryDone = true; + await drive(engine, 2); + // 深度显示卖一/买一严重偏离标记价 → 守卫拦截 + adapter.emitDepth({ lastUpdateId: 1, bids: [["80", "5"]], asks: [["80.5", "5"]] }); + adapter.emitTicker(ticker(config.symbol, 95)); + await drive(engine, 2); + expect(adapter.marketOrders.length).toBe(0); + expect(engine.getSnapshot().running).toBe(true); // 未完成止损,保持重试 + + // 深度恢复正常 → 完成平仓与停机 + adapter.emitDepth({ lastUpdateId: 2, bids: [["95", "5"]], asks: [["95.1", "5"]] }); + await drive(engine, 2); + expect(adapter.marketOrders.length).toBe(1); + expect(engine.getSnapshot().running).toBe(false); + engine.stop(); + }); - // Mark a buy level as "filled" — this simulates a previous ENTRY fill - const buyLevel = (engine as any).buyLevelIndices[0] as number; - (engine as any).levelStates.set(buyLevel, "filled"); - // Also mark in longExposure for the legacy path - (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); + it("layer 2/3: orphan position beyond line capacity gets a protective exit", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + // BUY 线容量 0.3(3 线 × 0.1),净多 0.35 → 0.05 无法归档 + const engine = await bootEngine(config, adapter, { positionAmt: 0.35, entryPrice: 141 }); + await drive(engine, 2); - // The legacy computeDesiredOrders skips levels present in longExposure - const desired = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string; intent: string }>; - const entryAtFilledLevel = desired.find( - (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" - ); - expect(entryAtFilledLevel).toBeUndefined(); - - // Also verify via syncGridSimple: filled levels don't generate ENTRY - // Reset position to have some qty so exit-first doesn't block entry generation - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - (engine as any).openOrders = []; - await (engine as any).syncGridSimple(150); - const desiredNew = (engine as any).desiredOrders as Array<{ level: number; intent: string }>; - const entryAtFilled = desiredNew.find( - (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" + const orphanExit = adapter.limitOrders.find( + (p) => p.side === "SELL" && Math.abs(Number(p.quantity ?? 0) - 0.05) < 1e-9 ); - expect(entryAtFilled).toBeUndefined(); - + expect(orphanExit).toBeTruthy(); + expect(Number(orphanExit!.price)).toBeGreaterThan(141); engine.stop(); }); - - it("releases level back to idle when EXIT fills (via longExposure legacy)", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); - adapter.emitOrders([]); + it("layer 2: deep floating loss closes the uncovered part at market", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig(); + // 入场价 160、现价 150 → 浮亏 6.25% > stopLossPct 1% → 未覆盖部分直接市价平 + const engine = await bootEngine(config, adapter, { positionAmt: 0.35, entryPrice: 160 }); + await drive(engine, 2); - const buyLevel = (engine as any).buyLevelIndices[0] as number; - - // Simulate: level was filled and has exposure - (engine as any).levelStates.set(buyLevel, "exit_placed"); - (engine as any).longExposure.set(buyLevel, baseConfig.orderSize); - - // Now clear the exposure (simulating EXIT fill) - (engine as any).longExposure.delete(buyLevel); - (engine as any).levelStates.set(buyLevel, "idle"); - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - - // The level should now accept a new ENTRY - const desired = (engine as any).computeDesiredOrders(150) as Array<{ level: number; side: string; intent: string }>; - const entryAtLevel = desired.find( - (d: { level: number; intent: string }) => d.level === buyLevel && d.intent === "ENTRY" + const close = adapter.marketOrders.find( + (p) => p.side === "SELL" && Math.abs(Number(p.quantity ?? 0) - 0.05) < 1e-9 ); - expect(entryAtLevel).toBeTruthy(); - + expect(close).toBeTruthy(); engine.stop(); }); - - it("EXIT orders are placed without reduceOnly flag", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, baseConfig.orderSize)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); - (engine as any).recoveryDone = true; + it("layer 4: maintains an exchange STOP_MARKET backstop when supported", async () => { + const adapter = new FakeAdapter(); + adapter.triggerOrders = true; + const config = makeConfig({ exchangeStopEnabled: true }); + const engine = await bootEngine(config, adapter, { positionAmt: 0.2, entryPrice: 141 }); + await drive(engine, 2); - // Set up a filled level so the engine wants to place an EXIT - const buyLevels = (engine as any).buyLevelIndices as number[]; - const buyLevel = buyLevels[buyLevels.length - 1]!; - const target = (engine as any).levelMeta[buyLevel]?.closeTarget; - - (engine as any).levelStates.set(buyLevel, "filled"); - if (target != null) { - (engine as any).exitTargetBySource.set(buyLevel, target); - } - - // Trigger syncGridSimple to attempt EXIT placement - await (engine as any).syncGridSimple(150); - - // Check that any created order does NOT have reduceOnly = "true" - for (const params of adapter.createdOrders) { - if (params.clientOrderId?.includes("-X-")) { - expect(params.reduceOnly).not.toBe("true"); - } - } - + expect(adapter.stopOrders.length).toBe(1); + expect(adapter.stopOrders[0]!.side).toBe("SELL"); + expect(Number(adapter.stopOrders[0]!.stopPrice)).toBeCloseTo(99, 6); + const snapshot = engine.getSnapshot(); + expect(snapshot.stopProtection.exchangeStop?.side).toBe("SELL"); + + // 仓位归零 → 撤销兜底单 + const stopId = snapshot.stopProtection.exchangeStop!.orderId; + adapter.emitAccount(accountSnapshot(config.symbol, 0, 0)); + clock.t += 6000; + await drive(engine, 2); + expect(adapter.cancelledIds).toContain(stopId); + expect(engine.getSnapshot().stopProtection.exchangeStop).toBeNull(); engine.stop(); }); - - it("all desired orders from computeDesiredOrders have intent field set", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - const desired = (engine as any).computeDesiredOrders(150) as Array<{ intent?: string }>; - for (const d of desired) { - expect(d.intent).toBeDefined(); - expect(["ENTRY", "EXIT"]).toContain(d.intent); - } - + it("layer 4: no exchange stop when the capability is missing", async () => { + const adapter = new FakeAdapter(); + adapter.triggerOrders = false; + const config = makeConfig({ exchangeStopEnabled: true }); + const engine = await bootEngine(config, adapter, { positionAmt: 0.2, entryPrice: 141 }); + await drive(engine, 3); + expect(adapter.stopOrders.length).toBe(0); engine.stop(); }); +}); + +// --------------------------------------------------------------------------- +// 智能移格 +// --------------------------------------------------------------------------- + +describe("GridEngine shift", () => { + it("full flow: debounce → cancel all → close position → rebuild around new anchor", async () => { + const adapter = new FakeAdapter(); + const config = makeConfig({ gridShiftEnabled: true }); + const engine = await bootEngine(config, adapter); + await drive(engine, 4); + expect(adapter.limitOrders.length).toBeGreaterThan(0); + + // 让引擎有持仓 + const entry = adapter.findOrders((o) => o.side === "BUY" && Number(o.price) === 141.4)[0]; + adapter.fillOrder(String(entry!.orderId)); + adapter.emitAccount(accountSnapshot(config.symbol, 0.1, 141.4)); + await drive(engine, 2); + + // 偏离锚定价 >5%,去抖 3s + adapter.emitTicker(ticker(config.symbol, 158)); + await drive(engine, 1); // 开始计时 + expect(engine.getSnapshot().shiftPhase).toBeNull(); + clock.t += 3500; + await drive(engine, 1); // 触发 BEGIN_SHIFT + expect(engine.getSnapshot().shiftPhase).toBe("cancelling"); + + await drive(engine, 2); // cancelAll + expect(adapter.cancelAllCount).toBeGreaterThanOrEqual(1); + await drive(engine, 2); // closing → 市价平仓 + expect(adapter.marketOrders.length).toBe(1); + adapter.emitAccount(accountSnapshot(config.symbol, 0, 0)); + await drive(engine, 3); // rebuilding → rebuild - it("snapshot includes level state for each grid line", () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); - const snapshot = engine.getSnapshot(); - expect(snapshot.gridLines.length).toBeGreaterThan(0); - for (const line of snapshot.gridLines) { - expect(line.state).toBeDefined(); - expect(["idle", "filled", "exit_placed"]).toContain(line.state); - } - + expect(snapshot.shiftPhase).toBeNull(); + expect(snapshot.gridVersion).toBe(2); + expect(snapshot.anchorPrice).toBeCloseTo(158, 0); + expect(snapshot.lowerPrice).toBeCloseTo(158 * 0.95, 1); + expect(snapshot.upperPrice).toBeCloseTo(158 * 1.05, 1); engine.stop(); }); - it("created orders contain clientOrderId with grid prefix", async () => { - const adapter = new StubAdapter(); - const engine = new GridEngine(baseConfig, adapter, { now: () => 0, skipPersistence: true }); - - adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); - adapter.emitOrders([]); - adapter.emitTicker({ - symbol: baseConfig.symbol, - lastPrice: "150", - openPrice: "150", - highPrice: "150", - lowPrice: "150", - volume: "0", - quoteVolume: "0", - }); + it("resumes an interrupted shift from the persisted phase", async () => { + await useTmpStorage(); + const adapter = new FakeAdapter(); + const config = makeConfig({ gridShiftEnabled: true }); + // 预写移格中断现场:closing 阶段 + 残留空头仓位 + const settings: GridLogicSettings = { + direction: "neutral", + lowerPrice: config.lowerPrice, + upperPrice: config.upperPrice, + gridLevels: config.gridLevels, + orderSize: config.orderSize, + maxPositionSize: config.maxPositionSize, + priceTick: config.priceTick, + qtyStep: config.qtyStep, + stopLossPct: config.stopLossPct, + uncoveredGraceMs: config.uncoveredGraceMs, + shiftEnabled: true, + shiftTriggerPct: config.gridShiftTriggerPct, + shiftRangePct: config.gridShiftRangePct, + shiftConfirmMs: config.gridShiftConfirmMs, + }; + const state = createInitialState(settings, 150); + state.shift = { phase: "closing", targetAnchor: 158, startedAt: clock.t }; + await saveGridState( + toStored( + state, + { + symbol: config.symbol, + exchangeId: "aster", + direction: "neutral", + orderSize: config.orderSize, + maxPositionSize: config.maxPositionSize, + gridLevels: config.gridLevels, + gridMode: "geometric", + }, + clock.t + ) + ); - (engine as any).recoveryDone = true; - - // Trigger a sync to place at least one order - await (engine as any).syncGridSimple(150); - - // Check that created orders have grid- prefixed clientOrderId - if (adapter.createdOrders.length > 0) { - for (const params of adapter.createdOrders) { - expect(params.clientOrderId).toBeDefined(); - expect(params.clientOrderId!.startsWith("grid-")).toBe(true); - } - } - + const engine = new GridEngine(config, adapter, { now: () => clock.t, skipPersistence: false }); + bootFeeds(adapter, config, { positionAmt: -0.2, entryPrice: 150, price: 158 }); + await settle(); + await drive(engine, 2); + expect(engine.getSnapshot().shiftPhase).not.toBeNull(); + // closing 续跑:市价买回空头 + expect(adapter.marketOrders.length).toBe(1); + expect(adapter.marketOrders[0]!.side).toBe("BUY"); + + adapter.emitAccount(accountSnapshot(config.symbol, 0, 0)); + await drive(engine, 3); + const snapshot = engine.getSnapshot(); + expect(snapshot.shiftPhase).toBeNull(); + expect(snapshot.gridVersion).toBe(2); + expect(snapshot.anchorPrice).toBeCloseTo(158, 0); engine.stop(); }); }); diff --git a/tests/grid-storage.test.ts b/tests/grid-storage.test.ts new file mode 100644 index 0000000..25e51bf --- /dev/null +++ b/tests/grid-storage.test.ts @@ -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); + }); +});