mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 添加基础网格策略支持,更新环境配置示例和文档,增强 CLI 和 UI 界面
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
## 文档索引
|
||||
- [English README](README_en.md)
|
||||
- [简明上手指南(零基础)](simple-readme.md)
|
||||
- [基础网格策略使用教程](grid-trading.md)
|
||||
|
||||
## 核心特性
|
||||
- **实时行情与风控**:Websocket + REST 自动同步账户、挂单与仓位,断线后自动恢复。
|
||||
|
||||
@@ -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 合约上运行一个自动化的等比网格策略。请务必先在沙盒或小仓位测试,确保参数适应当前波动性和手续费结构,再逐步提升资金规模。
|
||||
|
||||
祝交易顺利!
|
||||
+3
-3
@@ -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<StrategyId>(["trend", "maker", "offset-maker", "basis"]);
|
||||
const STRATEGY_VALUES = new Set<StrategyId>(["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 <trend|maker|offset-maker|basis>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
|
||||
console.log(`Usage: bun run index.ts [--strategy <trend|maker|offset-maker|basis|grid>] [--exchange <aster|grvt|lighter|backpack>] [--silent]\n\n` +
|
||||
`Options:\n` +
|
||||
` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` +
|
||||
` Aliases: offset, offset-maker for the offset maker engine.\n` +
|
||||
|
||||
@@ -1,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<StrategyId, string> = {
|
||||
maker: "Maker",
|
||||
"offset-maker": "Offset Maker",
|
||||
basis: "Basis Arbitrage",
|
||||
grid: "Grid",
|
||||
};
|
||||
|
||||
export async function startStrategy(strategyId: StrategyId, options: RunnerOptions = {}): Promise<void> {
|
||||
@@ -92,6 +91,19 @@ const STRATEGY_FACTORIES: Record<StrategyId, StrategyRunner> = {
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
grid: async (opts) => {
|
||||
const config = gridConfig;
|
||||
const adapter = createAdapterOrThrow(config.symbol);
|
||||
const engine = new GridEngine(config, adapter);
|
||||
await runEngine({
|
||||
engine,
|
||||
strategy: "grid",
|
||||
silent: opts.silent,
|
||||
getSnapshot: () => engine.getSnapshot(),
|
||||
onUpdate: (emitter) => engine.on("update", emitter),
|
||||
offUpdate: (emitter) => engine.off("update", emitter),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
interface EngineHarness<TSnapshot> {
|
||||
@@ -103,7 +115,9 @@ interface EngineHarness<TSnapshot> {
|
||||
offUpdate: (handler: (snapshot: TSnapshot) => void) => void;
|
||||
}
|
||||
|
||||
async function runEngine<TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot>(
|
||||
async function runEngine<
|
||||
TSnapshot extends TrendEngineSnapshot | MakerEngineSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot
|
||||
>(
|
||||
harness: EngineHarness<TSnapshot>
|
||||
): Promise<void> {
|
||||
const { engine, strategy, silent, getSnapshot, onUpdate, offUpdate } = harness;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AsterOrder | undefined> {
|
||||
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);
|
||||
|
||||
@@ -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<typeof createTradeLog>;
|
||||
private readonly events = new StrategyEventEmitter<GridEvent, GridEngineSnapshot>();
|
||||
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<typeof setInterval> | 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<AsterAccountSnapshot>(
|
||||
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<AsterOrder[]>(
|
||||
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<AsterDepth>(
|
||||
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<AsterTicker>(
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, AsterOrder>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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: "偏移做市策略",
|
||||
|
||||
@@ -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<GridEngineSnapshot | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const engineRef = useRef<GridEngine | null>(null);
|
||||
const exchangeId = useMemo(() => resolveExchangeId(), []);
|
||||
const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.escape) {
|
||||
engineRef.current?.stop();
|
||||
onExit();
|
||||
}
|
||||
},
|
||||
{ isActive: inputSupported }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const adapter = buildAdapterFromEnv({ exchangeId, symbol: gridConfig.symbol });
|
||||
const engine = new GridEngine(gridConfig, adapter);
|
||||
engineRef.current = engine;
|
||||
setSnapshot(engine.getSnapshot());
|
||||
const handler = (next: GridEngineSnapshot) => {
|
||||
setSnapshot({
|
||||
...next,
|
||||
desiredOrders: [...next.desiredOrders],
|
||||
gridLines: [...next.gridLines],
|
||||
tradeLog: [...next.tradeLog],
|
||||
});
|
||||
};
|
||||
engine.on("update", handler);
|
||||
engine.start();
|
||||
return () => {
|
||||
engine.off("update", handler);
|
||||
engine.stop();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [exchangeId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Text color="red">启动失败: {error.message}</Text>
|
||||
<Text color="gray">请检查环境变量和网络连通性。</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text>正在初始化网格策略…</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const feedStatus = snapshot.feedStatus;
|
||||
const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [
|
||||
{ key: "account", label: "账户" },
|
||||
{ key: "orders", label: "订单" },
|
||||
{ key: "depth", label: "深度" },
|
||||
{ key: "ticker", label: "行情" },
|
||||
];
|
||||
const stopReason = snapshot.running ? null : snapshot.stopReason;
|
||||
const lastLogs = snapshot.tradeLog.slice(-5);
|
||||
const position = snapshot.position;
|
||||
const hasPosition = Math.abs(position.positionAmt) > 1e-5;
|
||||
|
||||
const gridColumns: TableColumn[] = [
|
||||
{ key: "level", header: "#", align: "right", minWidth: 3 },
|
||||
{ key: "price", header: "Price", align: "right", minWidth: 10 },
|
||||
{ key: "side", header: "Side", minWidth: 4 },
|
||||
{ key: "active", header: "Active", minWidth: 6 },
|
||||
{ key: "hasOrder", header: "Order", minWidth: 5 },
|
||||
{ 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 (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="cyanBright">Grid Strategy Dashboard</Text>
|
||||
<Text>
|
||||
交易所: {exchangeName} | 交易对: {snapshot.symbol} | 状态: {snapshot.running ? "运行中" : "暂停"} | 方向: {snapshot.direction}
|
||||
</Text>
|
||||
<Text>
|
||||
实时价格: {formatNumber(snapshot.lastPrice, 4)} | 下界: {formatNumber(snapshot.lowerPrice, 4)} | 上界: {formatNumber(snapshot.upperPrice, 4)} | 网格数量: {snapshot.gridLines.length}
|
||||
</Text>
|
||||
<Text color="gray">数据状态:
|
||||
{feedEntries.map((entry, index) => (
|
||||
<Text key={entry.key} color={feedStatus[entry.key] ? "green" : "red"}>
|
||||
{index === 0 ? " " : " "}
|
||||
{entry.label}
|
||||
</Text>
|
||||
))}
|
||||
| 按 Esc 返回策略选择
|
||||
</Text>
|
||||
{stopReason ? <Text color="yellow">暂停原因: {stopReason}</Text> : null}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box flexDirection="column" marginRight={4}>
|
||||
<Text color="greenBright">网格配置</Text>
|
||||
<Text>
|
||||
单笔数量: {formatNumber(gridConfig.orderSize, 6)} | 最大仓位: {formatNumber(gridConfig.maxPositionSize, 6)}
|
||||
</Text>
|
||||
<Text>
|
||||
止损阈值: {(gridConfig.stopLossPct * 100).toFixed(2)}% | 重启阈值: {(gridConfig.restartTriggerPct * 100).toFixed(2)}% | 自动重启: {gridConfig.autoRestart ? "启用" : "关闭"}
|
||||
</Text>
|
||||
<Text>
|
||||
刷新间隔: {gridConfig.refreshIntervalMs} ms
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color="greenBright">持仓</Text>
|
||||
{hasPosition ? (
|
||||
<>
|
||||
<Text>
|
||||
当前持仓: {position.positionAmt > 0 ? "多" : "空"} | 数量: {formatNumber(Math.abs(position.positionAmt), 6)} | 均价: {formatNumber(position.entryPrice, 4)}
|
||||
</Text>
|
||||
<Text>
|
||||
未实现盈亏: {formatNumber(position.unrealizedProfit, 4)} | 标记价: {formatNumber(position.markPrice, 4)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text color="gray">当前无持仓</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">网格线</Text>
|
||||
{gridRows.length > 0 ? <DataTable columns={gridColumns} rows={gridRows} /> : <Text color="gray">暂无网格线</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color="yellow">目标挂单</Text>
|
||||
{desiredRows.length > 0 ? <DataTable columns={desiredColumns} rows={desiredRows} /> : <Text color="gray">暂无目标挂单</Text>}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Text color="yellow">最近事件</Text>
|
||||
{lastLogs.length > 0 ? (
|
||||
lastLogs.map((item, index) => (
|
||||
<Text key={`${item.time}-${index}`}>
|
||||
[{item.time}] [{item.type}] {item.detail}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text color="gray">暂无日志</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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<AsterOrder> {
|
||||
const order: AsterOrder = {
|
||||
orderId: `${Date.now()}-${Math.random()}`,
|
||||
clientOrderId: "test",
|
||||
symbol: params.symbol,
|
||||
side: params.side,
|
||||
type: params.type,
|
||||
status: params.type === "MARKET" ? "FILLED" : "NEW",
|
||||
price: Number(params.price ?? 0).toString(),
|
||||
origQty: Number(params.quantity ?? 0).toString(),
|
||||
executedQty: "0",
|
||||
stopPrice: "0",
|
||||
time: Date.now(),
|
||||
updateTime: Date.now(),
|
||||
reduceOnly: params.reduceOnly === "true",
|
||||
closePosition: false,
|
||||
};
|
||||
this.createdOrders.push(params);
|
||||
if (params.type === "MARKET") {
|
||||
this.marketOrders.push(params);
|
||||
this.orderHandler?.([]);
|
||||
} else {
|
||||
this.currentOrders = [order];
|
||||
this.orderHandler?.(this.currentOrders);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async cancelOrder(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async cancelOrders(): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
|
||||
async cancelAllOrders(): Promise<void> {
|
||||
this.cancelAllCount += 1;
|
||||
this.currentOrders = [];
|
||||
this.orderHandler?.([]);
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountSnapshot(symbol: string, positionAmt: number): AsterAccountSnapshot {
|
||||
return {
|
||||
canTrade: true,
|
||||
canDeposit: true,
|
||||
canWithdraw: true,
|
||||
updateTime: Date.now(),
|
||||
totalWalletBalance: "0",
|
||||
totalUnrealizedProfit: "0",
|
||||
positions: [
|
||||
{
|
||||
symbol,
|
||||
positionAmt: positionAmt.toString(),
|
||||
entryPrice: "150",
|
||||
unrealizedProfit: "0",
|
||||
positionSide: "BOTH",
|
||||
updateTime: Date.now(),
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
} as unknown as AsterAccountSnapshot;
|
||||
}
|
||||
|
||||
describe("GridEngine", () => {
|
||||
const baseConfig: GridConfig = {
|
||||
symbol: "BTCUSDT",
|
||||
lowerPrice: 100,
|
||||
upperPrice: 200,
|
||||
gridLevels: 3,
|
||||
orderSize: 0.1,
|
||||
maxPositionSize: 0.2,
|
||||
refreshIntervalMs: 10,
|
||||
maxLogEntries: 50,
|
||||
priceTick: 0.1,
|
||||
qtyStep: 0.01,
|
||||
direction: "both",
|
||||
stopLossPct: 0.01,
|
||||
restartTriggerPct: 0.01,
|
||||
autoRestart: true,
|
||||
gridMode: "geometric",
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user