diff --git a/.env.example b/.env.example index ac67526..d3b71f4 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,21 @@ MAKER_REFRESH_INTERVAL_MS=500 # Maker refresh cadence (ms) MAKER_MAX_CLOSE_SLIPPAGE_PCT=0.05 # Maker close slippage guard (fallbacks to MAX_CLOSE_SLIPPAGE_PCT) MAKER_PRICE_TICK=0.1 # Maker price tick size (defaults to PRICE_TICK) +# Grid strategy defaults +GRID_LOWER_PRICE=25000 # Grid lower bound price (quote currency) +GRID_UPPER_PRICE=35000 # Grid upper bound price +GRID_LEVELS=10 # Number of grid levels between bounds (>=2) +GRID_ORDER_SIZE=0.001 # Quantity per grid order (base asset units) +GRID_MAX_POSITION_SIZE=0.01 # Max inventory the grid may hold (base units) +GRID_REFRESH_INTERVAL_MS=1000 # Grid evaluation cadence (ms) +GRID_MAX_LOG_ENTRIES=200 # Grid trade log length (defaults to MAX_LOG_ENTRIES when unset) +GRID_DIRECTION=both # Order direction: both | long | short +GRID_STOP_LOSS_PCT=0.01 # Stop loss trigger percentage beyond bounds (0.01 => 1%) +GRID_RESTART_TRIGGER_PCT=0.01 # Restart buffer percentage inside bounds +GRID_AUTO_RESTART_ENABLED=true # Automatically resume grid when price re-enters range +# GRID_PRICE_TICK=0.1 # Optional override for grid price tick (falls back to PRICE_TICK) +# GRID_QTY_STEP=0.001 # Optional override for grid quantity step (falls back to QTY_STEP) + # GRVT authentication (set when EXCHANGE=grvt) GRVT_API_KEY= GRVT_API_SECRET= diff --git a/README.md b/README.md index 2212d95..de9938a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ ## 文档索引 - [English README](README_en.md) - [简明上手指南(零基础)](simple-readme.md) +- [基础网格策略使用教程](grid-trading.md) ## 核心特性 - **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。 diff --git a/grid-trading.md b/grid-trading.md new file mode 100644 index 0000000..99e35d5 --- /dev/null +++ b/grid-trading.md @@ -0,0 +1,98 @@ +# 网格交易策略使用教程 + +本文介绍如何在 Ritmex Bot 中使用全新的网格交易策略。我们将以 ASTERUSDT 永续合约为例,演示从环境配置到运行监控的完整流程,并对关键参数、风控机制、常见问题做出说明。 + +## 环境配置 + +1. 复制 `.env.example` 到 `.env` + ```bash + cp .env.example .env + ``` +2. 配置 Aster 交易所 API: + ```env + EXCHANGE=aster + ASTER_API_KEY=你的API密钥 + ASTER_API_SECRET=你的API密钥 + TRADE_SYMBOL=ASTERUSDT + ``` +3. 设置基础精度与网格参数(示例使用 1.50 ~ 2.50 区间,20 条网格,单笔 5 手,最大仓位 50 手): + ```env + PRICE_TICK=0.0001 + QTY_STEP=0.01 + + GRID_LOWER_PRICE=1.50 + GRID_UPPER_PRICE=2.50 + GRID_LEVELS=20 + GRID_ORDER_SIZE=5 + GRID_MAX_POSITION_SIZE=50 + GRID_REFRESH_INTERVAL_MS=1000 + GRID_MAX_LOG_ENTRIES=200 + GRID_DIRECTION=both + GRID_STOP_LOSS_PCT=0.02 + GRID_RESTART_TRIGGER_PCT=0.02 + GRID_AUTO_RESTART_ENABLED=true + ``` + +- `GRID_ORDER_SIZE` 与 `GRID_MAX_POSITION_SIZE` 需遵循「最大仓位 ÷ 单笔数量 ≥ 网格数」的原则,这样策略才能补齐全部挂单。本例 50 ÷ 5 = 10,但网格数为 20,意味着策略只会在离现价最近的上下各 10 个位置挂单,与仓位上限保持一致。 + +## 网格机制概览 + +- **几何等比网格**:所有网格价格基于上下边界按等比方式分布。 +- **基于现价的挂单排序**:重启或行情驱动时,会优先在现价附近补挂,避免远端挂单未成交。 +- **双向模式**:`GRID_DIRECTION=both` 表示买卖两侧都开仓;设置为 `long` 或 `short` 则只在对应方向发起新仓,反方向挂单会自动带上 `reduceOnly`。 +- **风控**: + - 跌破下界 * (1 - STOP_LOSS_PCT) 或突破上界 * (1 + STOP_LOSS_PCT) 时,策略撤销所有限价单并用市价平仓。 + - 若 `GRID_AUTO_RESTART_ENABLED=true`,当价格回到边界内 `RESTART_TRIGGER_PCT` 范围时会自动重启网格。 +- **持仓限制**:`GRID_MAX_POSITION_SIZE` 是总持仓上限,用于控制网格在极端走势中不会累积过量仓位。 + +## 运行命令 + +安装依赖后,使用 CLI 直接启动网格策略: +```bash +bun install +bun run index.ts --strategy grid --exchange aster +``` + +若要在 Ink Dashboard 中运行并交互,直接执行: +```bash +bun start +``` +然后在菜单中选择 “基础网格策略”。 + +## 监控与调优 + +界面主要包括: +- 当前买一/卖一、开仓方向、挂单/持仓概况。 +- 最近日志(订单状态、风控触发等)。 +- 触发止损后会清空网格并记录原因。 + +调参建议: +1. **缩短区间**:想拉高单格盈利,可缩小上下边界并减少网格数。 +2. **更精细挂单**:适当提高 `GRID_LEVELS` 并降低 `GRID_ORDER_SIZE`,但同时记得调大 `GRID_MAX_POSITION_SIZE`。 +3. **只做单边**:若只想高抛低吸不反手,可设 `GRID_DIRECTION=long`,卖单会变成 `reduceOnly`。 + +## 中断恢复行为 + +策略重启后会: +- 重新订阅账户、订单、深度、ticker; +- 基于当前持仓和开放订单重新计算网格,只补挂缺失部分; +- 在仓位额度允许的情况下持续追踪价位。 + +因此就算进程断掉,只要交易所回放的账号/订单快照完整,网格会从中断前的状态继续运行。若停机前手动撤过单,新启动时系统会把不在网格计划中的挂单一并清理。 + +## 常见问题 + +### Q: 为什么只有靠近现价的几个网格有订单? +A: 每笔网格单都会占用一定仓位上限。当 `GRID_MAX_POSITION_SIZE / GRID_ORDER_SIZE < GRID_LEVELS` 时,只会展示足以满足仓位限制的那几条网格。调整任一参数即可扩大覆盖面。 + +### Q: 价格突破上界后为何立即平仓? +A: 这是止损保护触发,避免庄外行情继续拉扯,默认 2% 触发后网格会全部撤单,并用市价平掉现有仓位。 + +### Q: 想要手动调仓怎么办? +A: 暂停策略(Ctrl+C 或 dashboard 退出)后手动操作,完成后再启动,策略会以新的仓位/挂单为基准重新布网。 + +## 小结 + +通过上述配置,你就可以在 ASTERUSDT 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。 + +祝交易顺利! diff --git a/src/cli/args.ts b/src/cli/args.ts index e23d06b..ac713c0 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -export type StrategyId = "trend" | "maker" | "offset-maker" | "basis"; +export type StrategyId = "trend" | "maker" | "offset-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; @@ -7,7 +7,7 @@ export interface CliOptions { exchange?: "aster" | "grvt" | "lighter" | "backpack"; } -const STRATEGY_VALUES = new Set(["trend", "maker", "offset-maker", "basis"]); +const STRATEGY_VALUES = new Set(["trend", "maker", "offset-maker", "basis", "grid"]); export function parseCliArgs(argv: string[] = process.argv.slice(2)): CliOptions { const options: CliOptions = { silent: false, help: false }; @@ -77,7 +77,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index bfad72e..aab72f6 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,14 +1,12 @@ -import { basisConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config"; +import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; -import { - MakerEngine, - type MakerEngineSnapshot, -} from "../strategy/maker-engine"; +import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; +import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; import { extractMessage } from "../utils/errors"; import type { StrategyId } from "./args"; @@ -23,6 +21,7 @@ export const STRATEGY_LABELS: Record = { maker: "Maker", "offset-maker": "Offset Maker", basis: "Basis Arbitrage", + grid: "Grid", }; export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise { @@ -92,6 +91,19 @@ const STRATEGY_FACTORIES: Record = { offUpdate: (emitter) => engine.off("update", emitter), }); }, + grid: async (opts) => { + const config = gridConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new GridEngine(config, adapter); + await runEngine({ + engine, + strategy: "grid", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, }; interface EngineHarness { @@ -103,7 +115,9 @@ interface EngineHarness { offUpdate: (handler: (snapshot: TSnapshot) => void) => void; } -async function runEngine( +async function runEngine< + TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot +>( harness: EngineHarness ): Promise { const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness; diff --git a/src/config.ts b/src/config.ts index 826fa28..7c7aeac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -52,6 +52,15 @@ function parseNumber(value: string | undefined, fallback: number): number { return Number.isFinite(next) ? next : fallback; } +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (!value) return fallback; + const normalized = value.trim().toLowerCase(); + if (!normalized) return fallback; + if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") return true; + if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") return false; + return fallback; +} + export const tradingConfig: TradingConfig = { symbol: resolveSymbolFromEnv(), tradeAmount: parseNumber(process.env.TRADE_AMOUNT, 0.001), @@ -106,6 +115,26 @@ export interface BasisArbConfig { takerFeeRate: number; } +export type GridDirection = "both" | "long" | "short"; + +export interface GridConfig { + symbol: string; + lowerPrice: number; + upperPrice: number; + gridLevels: number; + orderSize: number; + maxPositionSize: number; + refreshIntervalMs: number; + maxLogEntries: number; + priceTick: number; + qtyStep: number; + direction: GridDirection; + stopLossPct: number; + restartTriggerPct: number; + autoRestart: boolean; + gridMode: "geometric"; +} + const resolveBasisSymbol = (envKeys: string[], fallback: string): string => { for (const key of envKeys) { const value = process.env[key]; @@ -130,6 +159,42 @@ export const basisConfig: BasisArbConfig = { takerFeeRate: parseNumber(process.env.BASIS_TAKER_FEE_RATE, 0.0004), }; +const resolveGridDirection = (raw: string | undefined, fallback: GridDirection): GridDirection => { + if (!raw) return fallback; + const normalized = raw.trim().toLowerCase(); + if (normalized === "long" || normalized === "long-only") return "long"; + if (normalized === "short" || normalized === "short-only") return "short"; + if (normalized === "both" || normalized === "dual" || normalized === "bi" || normalized === "two-way") return "both"; + return fallback; +}; + +const resolveGridMaxPosition = (orderSize: number, levels: number): number => { + const fallback = Math.max(orderSize * Math.max(levels - 1, 1), orderSize); + const raw = process.env.GRID_MAX_POSITION_SIZE ?? process.env.GRID_MAX_POSITION ?? process.env.GRID_POSITION_CAP; + const parsed = parseNumber(raw, fallback); + return parsed > 0 ? parsed : fallback; +}; + +export const gridConfig: GridConfig = { + symbol: resolveSymbolFromEnv(), + lowerPrice: parseNumber(process.env.GRID_LOWER_PRICE ?? process.env.GRID_LOWER_BOUND, 0), + upperPrice: parseNumber(process.env.GRID_UPPER_PRICE ?? process.env.GRID_UPPER_BOUND, 0), + gridLevels: Math.max(2, Math.floor(parseNumber(process.env.GRID_LEVELS, 10))), + orderSize: parseNumber(process.env.GRID_ORDER_SIZE, parseNumber(process.env.TRADE_AMOUNT, 0.001)), + maxPositionSize: 0, // placeholder, replaced below + refreshIntervalMs: parseNumber(process.env.GRID_REFRESH_INTERVAL_MS, 1_000), + maxLogEntries: parseNumber(process.env.GRID_MAX_LOG_ENTRIES, 200), + priceTick: parseNumber(process.env.GRID_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), + qtyStep: parseNumber(process.env.GRID_QTY_STEP ?? process.env.QTY_STEP, 0.001), + direction: resolveGridDirection(process.env.GRID_DIRECTION, "both"), + stopLossPct: Math.max(0, parseNumber(process.env.GRID_STOP_LOSS_PCT, 0.01)), + restartTriggerPct: Math.max(0, parseNumber(process.env.GRID_RESTART_TRIGGER_PCT, 0.01)), + autoRestart: parseBoolean(process.env.GRID_AUTO_RESTART_ENABLED ?? process.env.GRID_ENABLE_AUTO_RESTART, true), + gridMode: "geometric", +}; + +gridConfig.maxPositionSize = resolveGridMaxPosition(gridConfig.orderSize, gridConfig.gridLevels); + export function isBasisStrategyEnabled(): boolean { const raw = process.env.ENABLE_BASIS_STRATEGY; if (!raw) return false; diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index bff87cc..aa81965 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -114,6 +114,12 @@ export async function deduplicateOrders( } } +type PlaceOrderOptions = { + priceTick: number; + qtyStep: number; + skipDedupe?: boolean; +}; + export async function placeOrder( adapter: ExchangeAdapter, symbol: string, @@ -127,7 +133,7 @@ export async function placeOrder( log: LogHandler, reduceOnly = false, guard?: OrderGuardOptions, - opts?: { priceTick: number; qtyStep: number } + opts?: PlaceOrderOptions ): Promise { const type = "LIMIT"; if (isOperating(locks, type)) return; @@ -144,7 +150,9 @@ export async function placeOrder( timeInForce: "GTX", }; if (reduceOnly) params.reduceOnly = "true"; - await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + if (!opts?.skipDedupe) { + await deduplicateOrders(adapter, symbol, openOrders, locks, timers, pendings, type, side, log); + } lockOperating(locks, timers, pendings, type, log); try { const order = await adapter.createOrder(params); diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts new file mode 100644 index 0000000..3b140cf --- /dev/null +++ b/src/strategy/grid-engine.ts @@ -0,0 +1,584 @@ +import type { GridConfig, GridDirection } from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { AsterAccountSnapshot, AsterDepth, AsterOrder, AsterTicker } from "../exchanges/types"; +import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; +import { decimalsOf } from "../utils/math"; +import { extractMessage } from "../utils/errors"; +import { getMidOrLast } from "../utils/price"; +import { getPosition, type PositionSnapshot } from "../utils/strategy"; +import { + placeMarketOrder, + placeOrder, + unlockOperating, + type OrderLockMap, + type OrderPendingMap, + type OrderTimerMap, +} from "../core/order-coordinator"; +import { safeCancelOrder } from "../core/lib/orders"; +import { StrategyEventEmitter } from "./common/event-emitter"; +import { safeSubscribe, type LogHandler } from "./common/subscriptions"; + +interface DesiredGridOrder { + level: number; + side: "BUY" | "SELL"; + price: string; + amount: number; + reduceOnly: boolean; +} + +interface GridLineSnapshot { + level: number; + price: number; + side: "BUY" | "SELL"; + active: boolean; + hasOrder: boolean; + reduceOnly: boolean; +} + +export interface GridEngineSnapshot { + ready: boolean; + symbol: string; + lowerPrice: number; + upperPrice: number; + lastPrice: number | null; + midPrice: number | null; + gridLines: GridLineSnapshot[]; + desiredOrders: DesiredGridOrder[]; + openOrders: AsterOrder[]; + position: PositionSnapshot; + running: boolean; + stopReason: string | null; + direction: GridDirection; + tradeLog: TradeLogEntry[]; + feedStatus: { + account: boolean; + orders: boolean; + depth: boolean; + ticker: boolean; + }; + lastUpdated: number | null; +} + +type GridEvent = "update"; +type GridListener = (snapshot: GridEngineSnapshot) => void; + +interface EngineOptions { + now?: () => number; +} + +const EPSILON = 1e-8; + +export class GridEngine { + private readonly tradeLog: ReturnType; + private readonly events = new StrategyEventEmitter(); + private readonly locks: OrderLockMap = {}; + private readonly timers: OrderTimerMap = {}; + private readonly pendings: OrderPendingMap = {}; + private readonly priceDecimals: number; + private readonly now: () => number; + private readonly configValid: boolean; + private readonly gridLevels: number[]; + + private accountSnapshot: AsterAccountSnapshot | null = null; + private depthSnapshot: AsterDepth | null = null; + private tickerSnapshot: AsterTicker | null = null; + private openOrders: AsterOrder[] = []; + + private position: PositionSnapshot = { positionAmt: 0, entryPrice: 0, unrealizedProfit: 0, markPrice: null }; + private desiredOrders: DesiredGridOrder[] = []; + + private readonly feedArrived = { + account: false, + orders: false, + depth: false, + ticker: false, + }; + + private readonly feedStatus = { + account: false, + orders: false, + depth: false, + ticker: false, + }; + + private readonly log: LogHandler; + + private timer: ReturnType | null = null; + private processing = false; + private running: boolean; + private stopReason: string | null = null; + private lastUpdated: number | null = null; + + 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.configValid = this.validateConfig(); + this.gridLevels = this.computeGridLevels(); + this.running = this.configValid; + if (!this.configValid) { + this.stopReason = "配置无效,已暂停网格"; + this.log("error", this.stopReason); + } + this.bootstrap(); + } + + start(): void { + if (this.timer || !this.running) { + if (!this.timer && !this.running) { + this.emitUpdate(); + } + return; + } + this.timer = setInterval(() => { + void this.tick(); + }, this.config.refreshIntervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + on(event: GridEvent, listener: GridListener): void { + this.events.on(event, listener); + } + + off(event: GridEvent, listener: GridListener): void { + this.events.off(event, listener); + } + + getSnapshot(): GridEngineSnapshot { + return this.buildSnapshot(); + } + + private 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; + } + return true; + } + + 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); + if (!this.feedArrived.account) { + this.feedArrived.account = true; + log("info", "账户快照已同步"); + } + this.feedStatus.account = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`, + processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`, + } + ); + + safeSubscribe( + this.exchange.watchOrders.bind(this.exchange), + (orders) => { + this.openOrders = Array.isArray(orders) + ? orders.filter((order) => order.symbol === this.config.symbol) + : []; + this.synchronizeLocks(orders); + if (!this.feedArrived.orders) { + this.feedArrived.orders = true; + log("info", "订单快照已同步"); + } + this.feedStatus.orders = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`, + processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`, + } + ); + + safeSubscribe( + this.exchange.watchDepth.bind(this.exchange, this.config.symbol), + (depth) => { + this.depthSnapshot = depth; + if (!this.feedArrived.depth) { + this.feedArrived.depth = true; + log("info", "盘口深度已同步"); + } + this.feedStatus.depth = true; + }, + log, + { + subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`, + processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`, + } + ); + + safeSubscribe( + this.exchange.watchTicker.bind(this.exchange, this.config.symbol), + (ticker) => { + this.tickerSnapshot = ticker; + if (!this.feedArrived.ticker) { + this.feedArrived.ticker = true; + log("info", "行情推送已同步"); + } + this.feedStatus.ticker = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`, + processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`, + } + ); + } + + private synchronizeLocks(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders : []; + Object.keys(this.pendings).forEach((type) => { + const pendingId = this.pendings[type]; + if (!pendingId) return; + const match = list.find((order) => String(order.orderId) === pendingId); + if (!match || (match.status && match.status !== "NEW")) { + unlockOperating(this.locks, this.timers, this.pendings, type); + } + }); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + try { + if (!this.running) { + await this.tryRestart(); + return; + } + if (!this.isReady()) { + return; + } + const price = this.getReferencePrice(); + if (!Number.isFinite(price) || price === null) { + return; + } + if (this.shouldStop(price)) { + await this.haltGrid(price); + return; + } + await this.syncGrid(price); + } catch (error) { + this.log("error", `网格轮询异常: ${extractMessage(error)}`); + } finally { + this.processing = false; + this.emitUpdate(); + } + } + + private isReady(): boolean { + return this.feedStatus.account && this.feedStatus.orders && this.feedStatus.ticker; + } + + private getReferencePrice(): number | null { + return getMidOrLast(this.depthSnapshot, this.tickerSnapshot); + } + + 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)}%`; + return true; + } + return false; + } + + private async haltGrid(price: number): Promise { + if (!this.running) return; + const reason = this.stopReason ?? "触发网格止损"; + this.log("warn", `${reason},开始执行平仓与撤单`); + 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; + } + + 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( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pendings, + side, + amount, + this.log, + true, + undefined, + { qtyStep: this.config.qtyStep } + ); + this.log("order", `市价平仓 ${side} ${amount}`); + } catch (error) { + this.log("error", `平仓失败: ${extractMessage(error)}`); + } finally { + unlockOperating(this.locks, this.timers, this.pendings, "MARKET"); + } + } + + 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 < lowerGuard || price > upperGuard) { + return; + } + this.log("info", "价格重新回到网格区间,恢复网格运行"); + this.running = true; + this.stopReason = null; + this.start(); + } + + private async syncGrid(price: number): Promise { + const desired = this.computeDesiredOrders(price); + this.desiredOrders = desired; + + const desiredKeys = new Set(desired.map((order) => this.getOrderKey(order.side, order.price))); + const activeOrders = this.openOrders.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT"); + const orderMap = new Map( + activeOrders.map((order) => [this.getOrderKey(order.side, this.normalizePrice(order.price)), order]) + ); + + for (const order of activeOrders) { + const key = this.getOrderKey(order.side, this.normalizePrice(order.price)); + if (desiredKeys.has(key)) continue; + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + (orderId) => { + this.log("order", `撤销网格单 #${orderId}: ${order.side} @ ${order.price}`); + orderMap.delete(key); + }, + () => { + this.log("order", `撤销时订单已完成: ${order.orderId}`); + orderMap.delete(key); + }, + (error) => { + this.log("error", `撤销订单失败: ${extractMessage(error)}`); + } + ); + } + + for (const desiredOrder of desired) { + const key = this.getOrderKey(desiredOrder.side, desiredOrder.price); + if (orderMap.has(key)) continue; + try { + const placed = await placeOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pendings, + desiredOrder.side, + desiredOrder.price, + desiredOrder.amount, + this.log, + desiredOrder.reduceOnly, + undefined, + { priceTick: this.config.priceTick, qtyStep: this.config.qtyStep, skipDedupe: true } + ); + if (placed) { + orderMap.set(key, placed); + } + } catch (error) { + this.log("error", `挂单失败 (${desiredOrder.side} @ ${desiredOrder.price}): ${extractMessage(error)}`); + } + } + + this.lastUpdated = this.now(); + } + + private computeDesiredOrders(price: number): DesiredGridOrder[] { + if (!this.running || !this.gridLevels.length || !this.configValid) return []; + const desired: DesiredGridOrder[] = []; + const maxLongExposure = Math.max(this.config.maxPositionSize - Math.max(this.position.positionAmt, 0), 0); + const maxShortExposure = Math.max(this.config.maxPositionSize - Math.max(-this.position.positionAmt, 0), 0); + let remainingLongHeadroom = maxLongExposure; + let remainingShortHeadroom = maxShortExposure; + let availableToSell = Math.max(this.position.positionAmt, 0); + let availableToBuy = Math.max(-this.position.positionAmt, 0); + + const halfTick = this.config.priceTick / 2; + const belowPrice = this.gridLevels + .map((levelPrice, level) => ({ level, levelPrice })) + .filter(({ levelPrice }) => levelPrice < price - halfTick) + .sort((a, b) => b.levelPrice - a.levelPrice); + const abovePrice = this.gridLevels + .map((levelPrice, level) => ({ level, levelPrice })) + .filter(({ levelPrice }) => levelPrice > price + halfTick) + .sort((a, b) => a.levelPrice - b.levelPrice); + + for (const { level, levelPrice } of belowPrice) { + const amount = this.config.orderSize; + const reduceOnly = this.config.direction === "short"; + if (!reduceOnly) { + if (remainingLongHeadroom < amount - EPSILON) break; + remainingLongHeadroom -= amount; + } else { + if (availableToBuy < amount - EPSILON) continue; + availableToBuy -= amount; + } + desired.push({ + level, + side: "BUY", + price: this.formatPrice(levelPrice), + amount, + reduceOnly, + }); + } + + for (const { level, levelPrice } of abovePrice) { + const amount = this.config.orderSize; + const reduceOnly = this.config.direction === "long"; + if (!reduceOnly) { + if (remainingShortHeadroom < amount - EPSILON) break; + remainingShortHeadroom -= amount; + } else { + if (availableToSell < amount - EPSILON) continue; + availableToSell -= amount; + } + desired.push({ + level, + side: "SELL", + price: this.formatPrice(levelPrice), + amount, + reduceOnly, + }); + } + + return desired; + } + + 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))); + } + return levels; + } + return []; + } + + private buildSnapshot(): GridEngineSnapshot { + 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))); + const openOrderKeys = new Set( + this.openOrders + .filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT") + .map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price))) + ); + + const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => { + const desired = this.desiredOrders.find((order) => order.level === level); + const side = desired?.side ?? (price < (lastPrice ?? price) ? "BUY" : "SELL"); + const key = desired ? this.getOrderKey(desired.side, desired.price) : null; + const hasOrder = key ? openOrderKeys.has(key) : false; + const active = Boolean(desired && key && desiredKeys.has(key)); + return { + level, + price, + side, + active, + hasOrder, + reduceOnly: desired?.reduceOnly ?? false, + }; + }); + + return { + ready: this.isReady() && this.running, + symbol: this.config.symbol, + lowerPrice: this.config.lowerPrice, + upperPrice: this.config.upperPrice, + lastPrice, + midPrice, + gridLines, + desiredOrders: this.desiredOrders.slice(), + openOrders: this.openOrders.filter((order) => order.symbol === this.config.symbol), + position: this.position, + running: this.running, + stopReason: this.running ? null : this.stopReason, + direction: this.config.direction, + tradeLog: this.tradeLog.all().slice(), + feedStatus: { ...this.feedStatus }, + lastUpdated: this.lastUpdated, + }; + } + + private emitUpdate(): void { + this.events.emit("update", this.buildSnapshot()); + } + + private getOrderKey(side: "BUY" | "SELL", price: string): string { + return `${side}:${price}`; + } + + 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); + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d9b35de..d76cddb 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,13 +3,14 @@ import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; import { MakerApp } from "./MakerApp"; import { OffsetMakerApp } from "./OffsetMakerApp"; +import { GridApp } from "./GridApp"; import { BasisApp } from "./BasisApp"; import { isBasisStrategyEnabled } from "../config"; import { loadCopyrightFragments, verifyCopyrightIntegrity } from "../utils/copyright"; import { resolveExchangeId } from "../exchanges/create-adapter"; interface StrategyOption { - id: "trend" | "maker" | "offset-maker" | "basis"; + id: "trend" | "maker" | "offset-maker" | "basis" | "grid"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -28,6 +29,12 @@ const BASE_STRATEGIES: StrategyOption[] = [ description: "双边挂单提供流动性,自动追价与风控止损", component: MakerApp, }, + { + id: "grid", + label: "基础网格策略", + description: "在上下边界之间布设等比网格,自动加仓与减仓", + component: GridApp, + }, { id: "offset-maker", label: "偏移做市策略", diff --git a/src/ui/GridApp.tsx b/src/ui/GridApp.tsx new file mode 100644 index 0000000..f07bc98 --- /dev/null +++ b/src/ui/GridApp.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { gridConfig } from "../config"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; +import { GridEngine, type GridEngineSnapshot } from "../strategy/grid-engine"; +import { DataTable, type TableColumn } from "./components/DataTable"; +import { formatNumber } from "../utils/format"; + +interface GridAppProps { + onExit: () => void; +} + +const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); + +export function GridApp({ onExit }: GridAppProps) { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const engineRef = useRef(null); + const exchangeId = useMemo(() => resolveExchangeId(), []); + const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); + + useInput( + (input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + try { + const adapter = buildAdapterFromEnv({ exchangeId, symbol: gridConfig.symbol }); + const engine = new GridEngine(gridConfig, adapter); + engineRef.current = engine; + setSnapshot(engine.getSnapshot()); + const handler = (next: GridEngineSnapshot) => { + setSnapshot({ + ...next, + desiredOrders: [...next.desiredOrders], + gridLines: [...next.gridLines], + tradeLog: [...next.tradeLog], + }); + }; + engine.on("update", handler); + engine.start(); + return () => { + engine.off("update", handler); + engine.stop(); + }; + } catch (err) { + console.error(err); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, [exchangeId]); + + if (error) { + return ( + + 启动失败: {error.message} + 请检查环境变量和网络连通性。 + + ); + } + + if (!snapshot) { + return ( + + 正在初始化网格策略… + + ); + } + + const feedStatus = snapshot.feedStatus; + const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [ + { key: "account", label: "账户" }, + { key: "orders", label: "订单" }, + { key: "depth", label: "深度" }, + { key: "ticker", label: "行情" }, + ]; + const stopReason = snapshot.running ? null : snapshot.stopReason; + const lastLogs = snapshot.tradeLog.slice(-5); + const position = snapshot.position; + const hasPosition = Math.abs(position.positionAmt) > 1e-5; + + const gridColumns: TableColumn[] = [ + { key: "level", header: "#", align: "right", minWidth: 3 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "active", header: "Active", minWidth: 6 }, + { key: "hasOrder", header: "Order", minWidth: 5 }, + { key: "reduceOnly", header: "RO", minWidth: 4 }, + ]; + const gridRows = snapshot.gridLines.map((line) => ({ + level: line.level, + price: formatNumber(line.price, 4), + side: line.side, + active: line.active ? "yes" : "no", + hasOrder: line.hasOrder ? "yes" : "no", + reduceOnly: line.reduceOnly ? "yes" : "no", + })); + + const desiredColumns: TableColumn[] = [ + { key: "level", header: "#", align: "right", minWidth: 3 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "amount", header: "Qty", align: "right", minWidth: 8 }, + { key: "reduceOnly", header: "RO", minWidth: 4 }, + ]; + const desiredRows = snapshot.desiredOrders.map((order) => ({ + level: order.level, + side: order.side, + price: order.price, + amount: formatNumber(order.amount, 4), + reduceOnly: order.reduceOnly ? "yes" : "no", + })); + + return ( + + + Grid Strategy Dashboard + + 交易所: {exchangeName} | 交易对: {snapshot.symbol} | 状态: {snapshot.running ? "运行中" : "暂停"} | 方向: {snapshot.direction} + + + 实时价格: {formatNumber(snapshot.lastPrice, 4)} | 下界: {formatNumber(snapshot.lowerPrice, 4)} | 上界: {formatNumber(snapshot.upperPrice, 4)} | 网格数量: {snapshot.gridLines.length} + + 数据状态: + {feedEntries.map((entry, index) => ( + + {index === 0 ? " " : " "} + {entry.label} + + ))} + | 按 Esc 返回策略选择 + + {stopReason ? 暂停原因: {stopReason} : null} + + + + + 网格配置 + + 单笔数量: {formatNumber(gridConfig.orderSize, 6)} | 最大仓位: {formatNumber(gridConfig.maxPositionSize, 6)} + + + 止损阈值: {(gridConfig.stopLossPct * 100).toFixed(2)}% | 重启阈值: {(gridConfig.restartTriggerPct * 100).toFixed(2)}% | 自动重启: {gridConfig.autoRestart ? "启用" : "关闭"} + + + 刷新间隔: {gridConfig.refreshIntervalMs} ms + + + + 持仓 + {hasPosition ? ( + <> + + 当前持仓: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 6)} | 均价: {formatNumber(position.entryPrice, 4)} + + + 未实现盈亏: {formatNumber(position.unrealizedProfit, 4)} | 标记价: {formatNumber(position.markPrice, 4)} + + + ) : ( + 当前无持仓 + )} + + + + + 网格线 + {gridRows.length > 0 ? : 暂无网格线} + + + + 目标挂单 + {desiredRows.length > 0 ? : 暂无目标挂单} + + + + 最近事件 + {lastLogs.length > 0 ? ( + lastLogs.map((item, index) => ( + + [{item.time}] [{item.type}] {item.detail} + + )) + ) : ( + 暂无日志 + )} + + + ); +} diff --git a/tests/grid-engine.test.ts b/tests/grid-engine.test.ts new file mode 100644 index 0000000..8f7c044 --- /dev/null +++ b/tests/grid-engine.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { + AsterAccountSnapshot, + AsterDepth, + AsterOrder, + AsterTicker, + CreateOrderParams, +} from "../src/exchanges/types"; +import type { GridConfig } from "../src/config"; +import { GridEngine } from "../src/strategy/grid-engine"; + +class StubAdapter implements ExchangeAdapter { + id = "aster"; + + private accountHandler: ((snapshot: AsterAccountSnapshot) => void) | null = null; + private orderHandler: ((orders: AsterOrder[]) => void) | null = null; + private depthHandler: ((depth: AsterDepth) => void) | null = null; + private tickerHandler: ((ticker: AsterTicker) => void) | null = null; + private currentOrders: AsterOrder[] = []; + + public createdOrders: CreateOrderParams[] = []; + public marketOrders: CreateOrderParams[] = []; + public cancelAllCount = 0; + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(cb: (snapshot: AsterAccountSnapshot) => void): void { + this.accountHandler = cb; + } + + watchOrders(cb: (orders: AsterOrder[]) => void): void { + this.orderHandler = cb; + } + + watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void { + this.depthHandler = cb; + } + + watchTicker(_symbol: string, cb: (ticker: AsterTicker) => void): void { + this.tickerHandler = cb; + } + + watchKlines(): void { + // not used in tests + } + + emitAccount(snapshot: AsterAccountSnapshot): void { + this.accountHandler?.(snapshot); + } + + emitOrders(orders: AsterOrder[]): void { + this.orderHandler?.(orders); + } + + emitDepth(depth: AsterDepth): void { + this.depthHandler?.(depth); + } + + emitTicker(ticker: AsterTicker): void { + this.tickerHandler?.(ticker); + } + + async createOrder(params: CreateOrderParams): Promise { + const order: AsterOrder = { + orderId: `${Date.now()}-${Math.random()}`, + clientOrderId: "test", + symbol: params.symbol, + side: params.side, + type: params.type, + status: params.type === "MARKET" ? "FILLED" : "NEW", + price: Number(params.price ?? 0).toString(), + origQty: Number(params.quantity ?? 0).toString(), + executedQty: "0", + stopPrice: "0", + time: Date.now(), + updateTime: Date.now(), + reduceOnly: params.reduceOnly === "true", + closePosition: false, + }; + this.createdOrders.push(params); + if (params.type === "MARKET") { + this.marketOrders.push(params); + this.orderHandler?.([]); + } else { + this.currentOrders = [order]; + this.orderHandler?.(this.currentOrders); + } + return order; + } + + async cancelOrder(): Promise { + // no-op + } + + async cancelOrders(): Promise { + // no-op + } + + async cancelAllOrders(): Promise { + this.cancelAllCount += 1; + this.currentOrders = []; + this.orderHandler?.([]); + } +} + +function createAccountSnapshot(symbol: string, positionAmt: number): AsterAccountSnapshot { + return { + canTrade: true, + canDeposit: true, + canWithdraw: true, + updateTime: Date.now(), + totalWalletBalance: "0", + totalUnrealizedProfit: "0", + positions: [ + { + symbol, + positionAmt: positionAmt.toString(), + entryPrice: "150", + unrealizedProfit: "0", + positionSide: "BOTH", + updateTime: Date.now(), + }, + ], + assets: [], + } as unknown as AsterAccountSnapshot; +} + +describe("GridEngine", () => { + const baseConfig: GridConfig = { + symbol: "BTCUSDT", + lowerPrice: 100, + upperPrice: 200, + gridLevels: 3, + orderSize: 0.1, + maxPositionSize: 0.2, + refreshIntervalMs: 10, + maxLogEntries: 50, + priceTick: 0.1, + qtyStep: 0.01, + direction: "both", + stopLossPct: 0.01, + restartTriggerPct: 0.01, + autoRestart: true, + gridMode: "geometric", + }; + + it("creates geometric desired orders when running in both directions", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + // use internal syncGrid to generate orders without waiting for timers + const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; price: string }>; + expect(desired).toHaveLength(3); + const buyOrders = desired.filter((order) => order.side === "BUY"); + const sellOrders = desired.filter((order) => order.side === "SELL"); + expect(buyOrders).toHaveLength(2); + expect(sellOrders).toHaveLength(1); + expect(Number(buyOrders[0]?.price)).toBeCloseTo(141.4, 1); + expect(Number(buyOrders[1]?.price)).toBeCloseTo(100, 6); + expect(Number(sellOrders[0]?.price)).toBeCloseTo(200, 6); + + engine.stop(); + }); + + it("limits sell orders for long-only direction when no position is available", () => { + const adapter = new StubAdapter(); + const engine = new GridEngine({ ...baseConfig, direction: "long" }, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0)); + adapter.emitOrders([]); + + const desired = (engine as any).computeDesiredOrders(150) as Array<{ side: string; reduceOnly: boolean }>; + const sells = desired.filter((order) => order.side === "SELL"); + const buys = desired.filter((order) => order.side === "BUY"); + + expect(buys.length).toBeGreaterThan(0); + expect(sells).toHaveLength(0); + + engine.stop(); + }); + + it("halts the grid and closes positions when stop loss triggers", async () => { + const adapter = new StubAdapter(); + const engine = new GridEngine(baseConfig, adapter, { now: () => 0 }); + + adapter.emitAccount(createAccountSnapshot(baseConfig.symbol, 0.2)); + adapter.emitOrders([]); + adapter.emitTicker({ + symbol: baseConfig.symbol, + lastPrice: "150", + openPrice: "150", + highPrice: "150", + lowPrice: "150", + volume: "0", + quoteVolume: "0", + }); + + (engine as any).stopReason = "test stop"; + await (engine as any).haltGrid(90); + + expect(adapter.cancelAllCount).toBe(1); + expect(adapter.marketOrders).toHaveLength(1); + expect(engine.getSnapshot().running).toBe(false); + + engine.stop(); + }); +});