feat: 添加基础网格策略支持,更新环境配置示例和文档,增强 CLI 和 UI 界面

This commit is contained in:
discountry
2025-10-07 01:40:06 +08:00
parent d7a95ceb36
commit 5e65c7025d
11 changed files with 1222 additions and 12 deletions
+3 -3
View File
@@ -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` +
+20 -6
View File
@@ -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;
+65
View File
@@ -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;
+10 -2
View File
@@ -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);
+584
View File
@@ -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
View File
@@ -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: "偏移做市策略",
+196
View File
@@ -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>
);
}