mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
1161 lines
40 KiB
TypeScript
1161 lines
40 KiB
TypeScript
import type { GridConfig, GridDirection } from "../config";
|
||
import type { ExchangeAdapter } from "../exchanges/adapter";
|
||
import type { AccountSnapshot, Depth, Order, Ticker } from "../exchanges/types";
|
||
import { createTradeLog, type TradeLogEntry } from "../logging/trade-log";
|
||
import { extractMessage, isUnknownOrderError } from "../utils/errors";
|
||
import { getMidOrLast } from "../utils/price";
|
||
import { getPosition, type PositionSnapshot } from "../utils/strategy";
|
||
import {
|
||
marketClose,
|
||
placeOrder,
|
||
placeStopLossOrder,
|
||
unlockOperating,
|
||
type OrderLockMap,
|
||
type OrderPendingMap,
|
||
type OrderTimerMap,
|
||
} from "../core/order-coordinator";
|
||
import { StrategyEventEmitter } from "./common/event-emitter";
|
||
import { safeSubscribe, type LogHandler } from "./common/subscriptions";
|
||
import { clearGridState, loadGridState, saveGridState } from "./common/grid-storage";
|
||
import {
|
||
ORPHAN_LEVEL,
|
||
applyRebuild,
|
||
createInitialState,
|
||
desiredExchangeStop,
|
||
fromStored,
|
||
isCompatibleStoredState,
|
||
makeEntryClientOrderId,
|
||
makeExitClientOrderId,
|
||
planShiftStep,
|
||
planTick,
|
||
qtyEpsilon,
|
||
reconcile,
|
||
toStored,
|
||
type ExchangeStopState,
|
||
type GridLogicSettings,
|
||
type GridLogicState,
|
||
type GridPlanAction,
|
||
type GridTradeMode,
|
||
type LevelPhase,
|
||
type OrderIntentRecord,
|
||
type OrderView,
|
||
type ShiftPhase,
|
||
type Side,
|
||
type StateMeta,
|
||
} from "./grid-logic";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Snapshot types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface DesiredGridOrder {
|
||
level: number;
|
||
side: Side;
|
||
price: string;
|
||
amount: number;
|
||
intent: "ENTRY" | "EXIT";
|
||
}
|
||
|
||
export interface GridLineSnapshot {
|
||
level: number;
|
||
price: number;
|
||
side: Side | "-";
|
||
role: "entry-buy" | "entry-sell" | "none";
|
||
state: LevelPhase;
|
||
hasOrder: boolean;
|
||
holdQty: number;
|
||
}
|
||
|
||
export interface GridEngineSnapshot {
|
||
ready: boolean;
|
||
symbol: string;
|
||
lowerPrice: number;
|
||
upperPrice: number;
|
||
gridVersion: number;
|
||
anchorPrice: number | null;
|
||
shiftPhase: ShiftPhase | null;
|
||
lastPrice: number | null;
|
||
midPrice: number | null;
|
||
gridLines: GridLineSnapshot[];
|
||
desiredOrders: DesiredGridOrder[];
|
||
openOrders: Order[];
|
||
position: PositionSnapshot;
|
||
running: boolean;
|
||
stopReason: string | null;
|
||
direction: GridDirection;
|
||
stopProtection: {
|
||
uncoveredQty: number;
|
||
exchangeStop: ExchangeStopState | null;
|
||
};
|
||
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;
|
||
/** Skip disk persistence (for tests) */
|
||
skipPersistence?: boolean;
|
||
}
|
||
|
||
const EPSILON = 1e-8;
|
||
const FINAL_STATUSES = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED"]);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GridEngine:I/O 编排层。所有网格决策在 grid-logic.ts 的纯函数中完成。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class GridEngine {
|
||
static readonly LIMIT_COOLDOWN_MS = 3000;
|
||
static readonly STOP_SYNC_INTERVAL_MS = 5000;
|
||
static readonly STALE_PLACEMENT_MS = 20_000;
|
||
|
||
private readonly tradeLog: ReturnType<typeof createTradeLog>;
|
||
private readonly events = new StrategyEventEmitter<GridEvent, GridEngineSnapshot>();
|
||
private readonly locks: OrderLockMap = {};
|
||
private readonly timers: OrderTimerMap = {};
|
||
private readonly pendings: OrderPendingMap = {};
|
||
private readonly now: () => number;
|
||
private readonly skipPersistence: boolean;
|
||
private readonly configValid: boolean;
|
||
private readonly log: LogHandler;
|
||
|
||
private state: GridLogicState | null = null;
|
||
private initStarted = false;
|
||
private initDone = false;
|
||
|
||
private depthSnapshot: Depth | null = null;
|
||
private tickerSnapshot: Ticker | null = null;
|
||
private openOrders: Order[] = [];
|
||
private position: PositionSnapshot = {
|
||
positionAmt: 0,
|
||
entryPrice: 0,
|
||
unrealizedProfit: 0,
|
||
markPrice: null,
|
||
};
|
||
|
||
private readonly feedStatus = { account: false, orders: false, depth: false, ticker: false };
|
||
private readonly feedArrived = { account: false, orders: false, depth: false, ticker: false };
|
||
|
||
private accountVersion = 0;
|
||
private ordersVersion = 0;
|
||
private ordersFeedLastAt = 0;
|
||
private tickerLastAt = 0;
|
||
|
||
private frozen = false;
|
||
private restReconcilePending = false;
|
||
private lastReconcileAt = 0;
|
||
private lastStopSyncAt = 0;
|
||
private stopPlacedAt = 0;
|
||
private lastPlacementAt = 0;
|
||
private lastPlacementOrdersVersion = -1;
|
||
private lastLimitAttemptAt = 0;
|
||
private lastStalenessLogAt = 0;
|
||
private shiftCloseAccountVersion = -1;
|
||
private shiftCloseAt = 0;
|
||
|
||
private timer: ReturnType<typeof setInterval> | null = null;
|
||
private processing = false;
|
||
private running: boolean;
|
||
private stopReason: string | null = null;
|
||
private lastUpdated: number | null = null;
|
||
private savePending = false;
|
||
private uncoveredQty = 0;
|
||
private desiredOrders: DesiredGridOrder[] = [];
|
||
private precisionSync: Promise<void> | 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.now = options.now ?? Date.now;
|
||
this.skipPersistence = options.skipPersistence ?? false;
|
||
this.configValid = this.validateConfig();
|
||
this.running = this.configValid;
|
||
if (!this.configValid) {
|
||
this.stopReason = "配置无效,已暂停网格";
|
||
this.log("error", this.stopReason);
|
||
}
|
||
this.syncPrecision();
|
||
this.bootstrap();
|
||
this.setupConnectionProtection();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Settings / validation
|
||
// -----------------------------------------------------------------------
|
||
|
||
private validateConfig(): boolean {
|
||
if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) return false;
|
||
if (this.config.upperPrice <= this.config.lowerPrice) return false;
|
||
if (!Number.isFinite(this.config.gridLevels) || this.config.gridLevels < 2) return false;
|
||
if (!Number.isFinite(this.config.orderSize) || this.config.orderSize <= 0) return false;
|
||
if (!Number.isFinite(this.config.maxPositionSize) || this.config.maxPositionSize <= 0) return false;
|
||
if (!Number.isFinite(this.config.refreshIntervalMs) || this.config.refreshIntervalMs < 1) return false;
|
||
if (this.config.gridMode !== "geometric") return false;
|
||
return true;
|
||
}
|
||
|
||
private get tradeMode(): GridTradeMode {
|
||
return this.config.direction === "both" ? "neutral" : this.config.direction;
|
||
}
|
||
|
||
private logicSettings(): GridLogicSettings {
|
||
return {
|
||
direction: this.tradeMode,
|
||
lowerPrice: this.state?.lowerPrice ?? this.config.lowerPrice,
|
||
upperPrice: this.state?.upperPrice ?? this.config.upperPrice,
|
||
gridLevels: this.config.gridLevels,
|
||
orderSize: this.config.orderSize,
|
||
maxPositionSize: this.config.maxPositionSize,
|
||
priceTick: this.config.priceTick,
|
||
qtyStep: this.config.qtyStep,
|
||
stopLossPct: this.config.stopLossPct,
|
||
uncoveredGraceMs: this.config.uncoveredGraceMs,
|
||
shiftEnabled: this.config.gridShiftEnabled,
|
||
shiftTriggerPct: this.config.gridShiftTriggerPct,
|
||
shiftRangePct: this.config.gridShiftRangePct,
|
||
shiftConfirmMs: this.config.gridShiftConfirmMs,
|
||
};
|
||
}
|
||
|
||
private stateMeta(): StateMeta {
|
||
return {
|
||
symbol: this.config.symbol,
|
||
exchangeId: this.exchange.id,
|
||
direction: this.tradeMode,
|
||
orderSize: this.config.orderSize,
|
||
maxPositionSize: this.config.maxPositionSize,
|
||
gridLevels: this.config.gridLevels,
|
||
gridMode: this.config.gridMode,
|
||
};
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Precision sync
|
||
// -----------------------------------------------------------------------
|
||
|
||
private syncPrecision(): void {
|
||
if (this.precisionSync) return;
|
||
const getPrecision = this.exchange.getPrecision?.bind(this.exchange);
|
||
if (!getPrecision) return;
|
||
this.precisionSync = getPrecision()
|
||
.then((precision) => {
|
||
if (!precision) return;
|
||
let updated = false;
|
||
if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) {
|
||
if (Math.abs(precision.priceTick - this.config.priceTick) > 1e-12) {
|
||
this.config.priceTick = precision.priceTick;
|
||
updated = true;
|
||
}
|
||
}
|
||
if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) {
|
||
if (Math.abs(precision.qtyStep - this.config.qtyStep) > 1e-12) {
|
||
this.config.qtyStep = precision.qtyStep;
|
||
updated = true;
|
||
}
|
||
}
|
||
if (updated) {
|
||
this.log("info", `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}`);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
this.log("error", `同步精度失败: ${extractMessage(error)}`);
|
||
this.precisionSync = null;
|
||
setTimeout(() => this.syncPrecision(), 2000);
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Feed subscriptions / connection events
|
||
// -----------------------------------------------------------------------
|
||
|
||
private bootstrap(): void {
|
||
safeSubscribe<AccountSnapshot>(
|
||
this.exchange.watchAccount.bind(this.exchange),
|
||
(snapshot) => {
|
||
this.position = getPosition(snapshot, this.config.symbol);
|
||
this.accountVersion += 1;
|
||
if (!this.feedArrived.account) {
|
||
this.feedArrived.account = true;
|
||
this.log("info", "账户快照已同步");
|
||
}
|
||
this.feedStatus.account = true;
|
||
this.emitUpdate();
|
||
},
|
||
this.log,
|
||
{
|
||
subscribeFail: (error) => `订阅账户失败: ${extractMessage(error)}`,
|
||
processFail: (error) => `账户推送处理异常: ${extractMessage(error)}`,
|
||
}
|
||
);
|
||
|
||
safeSubscribe<Order[]>(
|
||
this.exchange.watchOrders.bind(this.exchange),
|
||
(orders) => {
|
||
this.openOrders = Array.isArray(orders)
|
||
? orders.filter((order) => order.symbol === this.config.symbol)
|
||
: [];
|
||
this.synchronizeLocks(this.openOrders);
|
||
this.ordersVersion += 1;
|
||
this.ordersFeedLastAt = this.now();
|
||
if (!this.feedArrived.orders) {
|
||
this.feedArrived.orders = true;
|
||
this.log("info", "订单快照已同步");
|
||
}
|
||
this.feedStatus.orders = true;
|
||
void this.attemptInit();
|
||
this.emitUpdate();
|
||
},
|
||
this.log,
|
||
{
|
||
subscribeFail: (error) => `订阅订单失败: ${extractMessage(error)}`,
|
||
processFail: (error) => `订单推送处理异常: ${extractMessage(error)}`,
|
||
}
|
||
);
|
||
|
||
safeSubscribe<Depth>(
|
||
this.exchange.watchDepth.bind(this.exchange, this.config.symbol),
|
||
(depth) => {
|
||
this.depthSnapshot = depth;
|
||
if (!this.feedArrived.depth) {
|
||
this.feedArrived.depth = true;
|
||
this.log("info", "盘口深度已同步");
|
||
}
|
||
this.feedStatus.depth = true;
|
||
},
|
||
this.log,
|
||
{
|
||
subscribeFail: (error) => `订阅深度失败: ${extractMessage(error)}`,
|
||
processFail: (error) => `深度推送处理异常: ${extractMessage(error)}`,
|
||
}
|
||
);
|
||
|
||
safeSubscribe<Ticker>(
|
||
this.exchange.watchTicker.bind(this.exchange, this.config.symbol),
|
||
(ticker) => {
|
||
this.tickerSnapshot = ticker;
|
||
this.tickerLastAt = this.now();
|
||
if (!this.feedArrived.ticker) {
|
||
this.feedArrived.ticker = true;
|
||
this.log("info", "行情推送已同步");
|
||
}
|
||
this.feedStatus.ticker = true;
|
||
void this.attemptInit();
|
||
this.emitUpdate();
|
||
},
|
||
this.log,
|
||
{
|
||
subscribeFail: (error) => `订阅行情失败: ${extractMessage(error)}`,
|
||
processFail: (error) => `行情推送处理异常: ${extractMessage(error)}`,
|
||
}
|
||
);
|
||
}
|
||
|
||
private setupConnectionProtection(): void {
|
||
if (!this.exchange.onConnectionEvent) return;
|
||
this.exchange.onConnectionEvent((event, symbol) => {
|
||
if (event === "disconnected") {
|
||
this.frozen = true;
|
||
this.log("warn", `WebSocket 断连 (${symbol}),冻结网格下单`);
|
||
} else if (event === "reconnected") {
|
||
this.frozen = false;
|
||
this.restReconcilePending = true;
|
||
this.log("info", `WebSocket 重连成功 (${symbol}),下一轮执行对账`);
|
||
}
|
||
this.emitUpdate();
|
||
});
|
||
}
|
||
|
||
private synchronizeLocks(orders: Order[] | null | undefined): void {
|
||
const list = Array.isArray(orders) ? orders : [];
|
||
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) {
|
||
unlockOperating(this.locks, this.timers, this.pendings, type);
|
||
return;
|
||
}
|
||
const status = String(match.status || "").toUpperCase();
|
||
if (FINAL_STATUSES.has(status)) {
|
||
unlockOperating(this.locks, this.timers, this.pendings, type);
|
||
}
|
||
});
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Order helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
private isExchangeStopOrder(order: Order): boolean {
|
||
if (order.symbol !== this.config.symbol) return false;
|
||
const type = String(order.type || "").toUpperCase();
|
||
if (type.includes("STOP")) return true;
|
||
const stopPrice = Number(order.stopPrice);
|
||
return Number.isFinite(stopPrice) && stopPrice > 0;
|
||
}
|
||
|
||
private isActiveGridLimitOrder(order: Order): boolean {
|
||
if (order.symbol !== this.config.symbol) return false;
|
||
if (order.type !== "LIMIT") return false;
|
||
if (this.isExchangeStopOrder(order)) return false;
|
||
if (this.state?.exchangeStop && String(order.orderId) === this.state.exchangeStop.orderId) {
|
||
return false;
|
||
}
|
||
const status = String(order.status || "").toUpperCase();
|
||
return !FINAL_STATUSES.has(status);
|
||
}
|
||
|
||
private activeGridLimitOrders(source?: Order[]): Order[] {
|
||
return (source ?? this.openOrders).filter((order) => this.isActiveGridLimitOrder(order));
|
||
}
|
||
|
||
private toOrderView(order: Order): OrderView {
|
||
return {
|
||
orderId: String(order.orderId),
|
||
clientOrderId: order.clientOrderId || undefined,
|
||
side: order.side,
|
||
price: Number(order.price),
|
||
status: String(order.status || ""),
|
||
executedQty: Number(order.executedQty || 0),
|
||
origQty: Number(order.origQty || 0),
|
||
type: String(order.type || ""),
|
||
};
|
||
}
|
||
|
||
private symbolOrderViews(source?: Order[]): OrderView[] {
|
||
return (source ?? this.openOrders)
|
||
.filter((order) => order.symbol === this.config.symbol && !this.isExchangeStopOrder(order))
|
||
.map((order) => this.toOrderView(order));
|
||
}
|
||
|
||
private getReferencePrice(): number | null {
|
||
return getMidOrLast(this.depthSnapshot, this.tickerSnapshot);
|
||
}
|
||
|
||
private isReady(): boolean {
|
||
return this.feedStatus.account && this.feedStatus.orders && this.feedStatus.ticker;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 初始化:磁盘恢复 + 启动对账
|
||
// -----------------------------------------------------------------------
|
||
|
||
private async attemptInit(): Promise<void> {
|
||
if (this.state || this.initStarted || !this.configValid) return;
|
||
if (!this.feedStatus.orders || !this.feedStatus.account) return;
|
||
const price = this.getReferencePrice();
|
||
if (price == null || !Number.isFinite(price)) return;
|
||
this.initStarted = true;
|
||
try {
|
||
let stored = null;
|
||
if (!this.skipPersistence) {
|
||
try {
|
||
stored = await loadGridState(this.config.symbol);
|
||
} catch (err) {
|
||
this.log("error", `加载网格状态失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
const meta = this.stateMeta();
|
||
if (stored && isCompatibleStoredState(stored, meta)) {
|
||
this.state = fromStored(stored, this.logicSettings(), price);
|
||
this.log(
|
||
"info",
|
||
`已从磁盘恢复网格状态: gridVersion=${this.state.gridVersion} anchor=${this.state.anchorPrice} ` +
|
||
`区间=[${this.state.lowerPrice}, ${this.state.upperPrice}]${this.state.shift ? ` 移格续跑(${this.state.shift.phase})` : ""}`
|
||
);
|
||
} else {
|
||
if (stored) {
|
||
this.log("warn", "磁盘网格状态与当前配置指纹不一致,全新建格并执行孤儿扫描");
|
||
}
|
||
this.state = createInitialState(this.logicSettings(), price);
|
||
this.log("info", `以锚定价 ${this.state.anchorPrice} 建立网格 (${this.tradeMode})`);
|
||
}
|
||
await this.applyReconcile(this.openOrders, "startup");
|
||
this.initDone = true;
|
||
} catch (err) {
|
||
this.log("error", `网格初始化失败: ${extractMessage(err)}`);
|
||
this.initStarted = false;
|
||
}
|
||
this.emitUpdate();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 对账(重启 / 重连 / 周期 REST)
|
||
// -----------------------------------------------------------------------
|
||
|
||
private async applyReconcile(orders: Order[], source: string): Promise<void> {
|
||
const state = this.state;
|
||
if (!state) return;
|
||
const result = reconcile(state, this.logicSettings(), {
|
||
activeOrders: this.activeGridLimitOrders(orders).map((o) => this.toOrderView(o)),
|
||
positionAmt: this.position.positionAmt,
|
||
price: this.getReferencePrice(),
|
||
now: this.now(),
|
||
});
|
||
for (const event of result.events) {
|
||
this.log("info", `[对账:${source}] ${event}`);
|
||
}
|
||
if (result.cancelOrderIds.length > 0) {
|
||
try {
|
||
await this.exchange.cancelOrders({
|
||
symbol: this.config.symbol,
|
||
orderIdList: result.cancelOrderIds,
|
||
});
|
||
this.log("order", `[对账:${source}] 撤销 ${result.cancelOrderIds.length} 个无法归属的挂单`);
|
||
} catch (err) {
|
||
if (!isUnknownOrderError(err)) {
|
||
this.log("error", `[对账:${source}] 撤单失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
}
|
||
this.lastReconcileAt = this.now();
|
||
await this.persistNow();
|
||
}
|
||
|
||
private async runRestReconcile(source: string): Promise<void> {
|
||
if (!this.state) return;
|
||
let orders: Order[] | null = null;
|
||
if (this.exchange.queryOpenOrders) {
|
||
try {
|
||
const fetched = await this.exchange.queryOpenOrders();
|
||
orders = fetched.filter((order) => order.symbol === this.config.symbol);
|
||
} catch (err) {
|
||
this.log("error", `[对账:${source}] REST 查询挂单失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
if (this.exchange.queryAccountSnapshot) {
|
||
try {
|
||
const snapshot = await this.exchange.queryAccountSnapshot();
|
||
if (snapshot) {
|
||
this.position = getPosition(snapshot, this.config.symbol);
|
||
this.accountVersion += 1;
|
||
}
|
||
} catch (err) {
|
||
this.log("error", `[对账:${source}] REST 查询账户失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
if (orders) {
|
||
this.openOrders = orders;
|
||
this.ordersVersion += 1;
|
||
this.ordersFeedLastAt = this.now();
|
||
}
|
||
await this.applyReconcile(orders ?? this.openOrders, source);
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Tick loop
|
||
// -----------------------------------------------------------------------
|
||
|
||
private async tick(): Promise<void> {
|
||
if (this.processing) return;
|
||
this.processing = true;
|
||
try {
|
||
if (!this.running) {
|
||
await this.tryRestart();
|
||
return;
|
||
}
|
||
if (!this.isReady()) return;
|
||
if (!this.initDone) {
|
||
await this.attemptInit();
|
||
if (!this.initDone) return;
|
||
}
|
||
if (!this.state) return;
|
||
if (this.frozen) return;
|
||
|
||
if (this.restReconcilePending) {
|
||
this.restReconcilePending = false;
|
||
await this.runRestReconcile("reconnect");
|
||
} else if (
|
||
this.exchange.queryOpenOrders &&
|
||
this.now() - this.lastReconcileAt >= this.config.reconcileIntervalMs
|
||
) {
|
||
await this.runRestReconcile("periodic");
|
||
}
|
||
|
||
const price = this.getReferencePrice();
|
||
if (price == null || !Number.isFinite(price)) return;
|
||
|
||
if (this.state.shift) {
|
||
await this.runShiftStep(price);
|
||
return;
|
||
}
|
||
|
||
const input = {
|
||
now: this.now(),
|
||
price,
|
||
positionAmt: this.position.positionAmt,
|
||
entryPrice: Number(this.position.entryPrice) || 0,
|
||
accountVersion: this.accountVersion,
|
||
activeOrders: this.activeGridLimitOrders().map((o) => this.toOrderView(o)),
|
||
allOrders: this.symbolOrderViews(),
|
||
};
|
||
const plan = planTick(this.state, this.logicSettings(), input);
|
||
for (const event of plan.events) {
|
||
this.log("info", event);
|
||
}
|
||
this.uncoveredQty = plan.uncoveredQty;
|
||
this.desiredOrders = plan.actions
|
||
.filter((a): a is Extract<GridPlanAction, { kind: "PLACE_ENTRY" | "PLACE_EXIT" }> =>
|
||
a.kind === "PLACE_ENTRY" || a.kind === "PLACE_EXIT"
|
||
)
|
||
.map((a) =>
|
||
a.kind === "PLACE_ENTRY"
|
||
? { level: a.level, side: a.side, price: a.price, amount: a.qty, intent: "ENTRY" as const }
|
||
: { level: a.target ?? ORPHAN_LEVEL, side: a.side, price: a.price, amount: a.qty, intent: "EXIT" as const }
|
||
);
|
||
|
||
await this.executeActions(plan.actions);
|
||
// halt / 移格启动后不再执行后续挂单与持久化(halt 已清盘)
|
||
if (!this.state || !this.running || this.state.shift) return;
|
||
|
||
await this.syncExchangeStop(price);
|
||
|
||
this.lastUpdated = this.now();
|
||
if (plan.stateChanged) {
|
||
this.schedulePersist();
|
||
}
|
||
} catch (error) {
|
||
this.log("error", `网格轮询异常: ${extractMessage(error)}`);
|
||
} finally {
|
||
this.processing = false;
|
||
this.emitUpdate();
|
||
}
|
||
}
|
||
|
||
private async executeActions(actions: GridPlanAction[]): Promise<void> {
|
||
let limitPlaced = false;
|
||
for (const action of actions) {
|
||
if (action.kind === "HALT") {
|
||
await this.haltGrid(action.reason);
|
||
return;
|
||
}
|
||
if (action.kind === "BEGIN_SHIFT") {
|
||
// 移格标记已由 planTick 写入 state,落盘后由下个 tick 开始执行
|
||
this.log("warn", `启动智能移格,目标锚定价 ${action.targetAnchor}`);
|
||
await this.persistNow();
|
||
return;
|
||
}
|
||
if (action.kind === "MARKET_CLOSE") {
|
||
await this.guardedMarketClose(action.side, action.qty, action.reason);
|
||
continue;
|
||
}
|
||
// PLACE_ENTRY / PLACE_EXIT:每 tick 最多 1 单
|
||
if (limitPlaced) continue;
|
||
if (!this.canPlaceLimitNow()) continue;
|
||
const placed = await this.placeGridOrder(action);
|
||
if (placed) limitPlaced = true;
|
||
}
|
||
}
|
||
|
||
private canPlaceLimitNow(): boolean {
|
||
if (this.pendings["LIMIT"]) return false;
|
||
const now = this.now();
|
||
const snapshotStale = this.lastPlacementOrdersVersion === this.ordersVersion;
|
||
const inCooldown = now - this.lastLimitAttemptAt < GridEngine.LIMIT_COOLDOWN_MS;
|
||
if (snapshotStale && inCooldown) return false;
|
||
// 陈旧性守卫:下过单但订单流一直没有反映,且行情仍在推送 → 冻结新下单
|
||
if (
|
||
this.lastPlacementAt > 0 &&
|
||
this.ordersFeedLastAt < this.lastPlacementAt &&
|
||
now - this.lastPlacementAt > GridEngine.STALE_PLACEMENT_MS &&
|
||
this.tickerLastAt > now - 10_000
|
||
) {
|
||
if (now - this.lastStalenessLogAt > 30_000) {
|
||
this.lastStalenessLogAt = now;
|
||
this.log("warn", "订单流疑似停滞(下单后长时间未反映),暂停新下单");
|
||
}
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private async placeGridOrder(
|
||
action: Extract<GridPlanAction, { kind: "PLACE_ENTRY" | "PLACE_EXIT" }>
|
||
): Promise<boolean> {
|
||
const state = this.state;
|
||
if (!state) return false;
|
||
const now = this.now();
|
||
const isEntry = action.kind === "PLACE_ENTRY";
|
||
const level = isEntry ? action.level : action.source;
|
||
const target = isEntry ? undefined : action.target ?? undefined;
|
||
const clientOrderId = isEntry
|
||
? makeEntryClientOrderId(state.gridVersion, action.level, now)
|
||
: makeExitClientOrderId(state.gridVersion, Math.max(action.source, 0), Math.max(action.target ?? 0, 0), now);
|
||
|
||
// write-ahead:先落 inflight 槽位再下单,消灭“交易所已接单本地未登记”的窗口
|
||
state.inflight = {
|
||
clientOrderId,
|
||
intent: isEntry ? "ENTRY" : "EXIT",
|
||
side: action.side,
|
||
price: action.price,
|
||
qty: action.qty,
|
||
level,
|
||
gridVersion: state.gridVersion,
|
||
createdAt: now,
|
||
};
|
||
if (target != null) state.inflight.target = target;
|
||
await this.persistNow();
|
||
|
||
let placed: Order | undefined;
|
||
const ordersVersionBeforePlace = this.ordersVersion;
|
||
try {
|
||
this.lastLimitAttemptAt = now;
|
||
placed = await placeOrder(
|
||
this.exchange,
|
||
this.config.symbol,
|
||
this.openOrders,
|
||
this.locks,
|
||
this.timers,
|
||
this.pendings,
|
||
action.side,
|
||
action.price,
|
||
action.qty,
|
||
this.log,
|
||
isEntry ? false : this.config.useReduceOnlyForExit,
|
||
undefined,
|
||
{
|
||
priceTick: this.config.priceTick,
|
||
qtyStep: this.config.qtyStep,
|
||
skipDedupe: true,
|
||
clientOrderId,
|
||
}
|
||
);
|
||
} catch (error) {
|
||
this.log("error", `挂单失败 (${action.side} @ ${action.price}): ${extractMessage(error)}`);
|
||
}
|
||
state.inflight = null;
|
||
|
||
if (placed?.orderId != null) {
|
||
const orderId = String(placed.orderId);
|
||
const record: OrderIntentRecord = {
|
||
orderId,
|
||
clientOrderId,
|
||
intent: isEntry ? "ENTRY" : "EXIT",
|
||
side: action.side,
|
||
price: action.price,
|
||
qty: action.qty,
|
||
level,
|
||
gridVersion: state.gridVersion,
|
||
createdAt: now,
|
||
};
|
||
if (target != null) record.target = target;
|
||
state.intents.set(orderId, record);
|
||
const levelRuntime = state.levels[level];
|
||
if (levelRuntime) {
|
||
if (isEntry) {
|
||
levelRuntime.phase = "entry_placed";
|
||
levelRuntime.entryOrderId = orderId;
|
||
} else {
|
||
levelRuntime.phase = "exit_placed";
|
||
levelRuntime.exitOrderId = orderId;
|
||
}
|
||
}
|
||
this.lastPlacementAt = now;
|
||
this.lastPlacementOrdersVersion = ordersVersionBeforePlace;
|
||
await this.persistNow();
|
||
return true;
|
||
}
|
||
await this.persistNow();
|
||
return false;
|
||
}
|
||
|
||
/** 市价平仓 + maxCloseSlippagePct 滑点守卫 */
|
||
private async guardedMarketClose(side: Side, qty: number, reason: string): Promise<boolean> {
|
||
const eps = qtyEpsilon(this.config);
|
||
if (qty <= eps) return true;
|
||
const mark = this.position.markPrice;
|
||
const depthBid = Number(this.depthSnapshot?.bids?.[0]?.[0]);
|
||
const depthAsk = Number(this.depthSnapshot?.asks?.[0]?.[0]);
|
||
const closeSidePrice = side === "SELL" ? depthBid : depthAsk;
|
||
const limitPct = this.config.maxCloseSlippagePct;
|
||
if (
|
||
mark != null &&
|
||
Number.isFinite(mark) &&
|
||
mark > 0 &&
|
||
Number.isFinite(closeSidePrice) &&
|
||
limitPct > 0
|
||
) {
|
||
const pctDiff = Math.abs(closeSidePrice - mark) / mark;
|
||
if (pctDiff > limitPct) {
|
||
this.log(
|
||
"warn",
|
||
`市价平仓滑点守卫触发 (${reason}): close=${closeSidePrice} mark=${mark} 偏离 ${(pctDiff * 100).toFixed(2)}% > ${(limitPct * 100).toFixed(2)}%,暂缓`
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
try {
|
||
await marketClose(
|
||
this.exchange,
|
||
this.config.symbol,
|
||
this.openOrders,
|
||
this.locks,
|
||
this.timers,
|
||
this.pendings,
|
||
side,
|
||
qty,
|
||
this.log,
|
||
{
|
||
markPrice: mark,
|
||
expectedPrice: Number.isFinite(closeSidePrice) ? closeSidePrice : null,
|
||
maxPct: limitPct > 0 ? limitPct : undefined,
|
||
},
|
||
{ qtyStep: this.config.qtyStep }
|
||
);
|
||
this.log("close", `市价平仓 ${side} ${qty} (${reason})`);
|
||
return true;
|
||
} catch (error) {
|
||
this.log("error", `市价平仓失败 (${reason}): ${extractMessage(error)}`);
|
||
return false;
|
||
} finally {
|
||
unlockOperating(this.locks, this.timers, this.pendings, "MARKET");
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 智能移格:cancelling → closing → rebuilding,每步幂等、phase 持久化
|
||
// -----------------------------------------------------------------------
|
||
|
||
private async runShiftStep(price: number): Promise<void> {
|
||
const state = this.state;
|
||
if (!state?.shift) return;
|
||
const step = planShiftStep(state, this.logicSettings(), {
|
||
activeOrderCount:
|
||
this.activeGridLimitOrders().length + (this.findLiveExchangeStop() ? 1 : 0),
|
||
positionAmt: this.position.positionAmt,
|
||
price,
|
||
});
|
||
if (step.kind === "CANCEL_ALL") {
|
||
try {
|
||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||
state.exchangeStop = null;
|
||
this.log("order", "移格: 已请求撤销全部挂单");
|
||
} catch (err) {
|
||
this.log("error", `移格撤单失败: ${extractMessage(err)}`);
|
||
}
|
||
} else if (step.kind === "CLOSE_POSITION") {
|
||
// 平仓单已提交但仓位回报未到时不重复提交
|
||
const awaitingFill =
|
||
this.shiftCloseAccountVersion === this.accountVersion &&
|
||
this.now() - this.shiftCloseAt < 10_000;
|
||
if (!awaitingFill) {
|
||
const done = await this.guardedMarketClose(step.side, step.qty, "移格平仓");
|
||
if (done) {
|
||
this.shiftCloseAccountVersion = this.accountVersion;
|
||
this.shiftCloseAt = this.now();
|
||
} else {
|
||
this.log("info", "移格: 平仓被滑点守卫暂缓,下轮重试");
|
||
}
|
||
}
|
||
} else if (step.kind === "REBUILD") {
|
||
applyRebuild(state, this.logicSettings(), step.anchor);
|
||
this.log(
|
||
"info",
|
||
`移格完成: 新锚定价 ${step.anchor},区间 [${state.lowerPrice.toFixed(4)}, ${state.upperPrice.toFixed(4)}],gridVersion=${state.gridVersion}`
|
||
);
|
||
}
|
||
this.lastUpdated = this.now();
|
||
await this.persistNow();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 止损层④:交易所侧 STOP_MARKET 兜底
|
||
// -----------------------------------------------------------------------
|
||
|
||
private findLiveExchangeStop(): Order | null {
|
||
const state = this.state;
|
||
if (!state?.exchangeStop) return null;
|
||
const match = this.openOrders.find(
|
||
(order) =>
|
||
String(order.orderId) === state.exchangeStop!.orderId &&
|
||
!FINAL_STATUSES.has(String(order.status || "").toUpperCase())
|
||
);
|
||
return match ?? null;
|
||
}
|
||
|
||
private async syncExchangeStop(price: number): Promise<void> {
|
||
const state = this.state;
|
||
if (!state) return;
|
||
if (!this.config.exchangeStopEnabled) return;
|
||
if (!(this.exchange.supportsTriggerOrders?.() ?? false)) return;
|
||
const now = this.now();
|
||
if (now - this.lastStopSyncAt < GridEngine.STOP_SYNC_INTERVAL_MS) return;
|
||
|
||
const desired = desiredExchangeStop(state, this.logicSettings(), this.position.positionAmt);
|
||
const existing = state.exchangeStop;
|
||
const live = this.findLiveExchangeStop();
|
||
|
||
if (!desired) {
|
||
if (existing) {
|
||
this.lastStopSyncAt = now;
|
||
if (live) {
|
||
try {
|
||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
||
this.log("order", "已撤销交易所兜底止损单(仓位归零)");
|
||
} catch (err) {
|
||
if (!isUnknownOrderError(err)) {
|
||
this.log("error", `撤销兜底止损单失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
}
|
||
state.exchangeStop = null;
|
||
this.schedulePersist();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const sideChanged = existing != null && existing.side !== desired.side;
|
||
const priceMoved =
|
||
existing != null && Math.abs(existing.stopPrice - desired.stopPrice) > this.config.priceTick;
|
||
const liveMissing =
|
||
existing != null && live == null && now - this.stopPlacedAt > 15_000;
|
||
if (existing && live && !sideChanged && !priceMoved) return;
|
||
if (existing && !live && !liveMissing) return;
|
||
|
||
this.lastStopSyncAt = now;
|
||
if (existing && live) {
|
||
try {
|
||
await this.exchange.cancelOrder({ symbol: this.config.symbol, orderId: existing.orderId });
|
||
} catch (err) {
|
||
if (!isUnknownOrderError(err)) {
|
||
this.log("error", `撤销旧兜底止损单失败: ${extractMessage(err)}`);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
state.exchangeStop = null;
|
||
|
||
const lastPrice = Number(this.tickerSnapshot?.lastPrice);
|
||
try {
|
||
const placed = await placeStopLossOrder(
|
||
this.exchange,
|
||
this.config.symbol,
|
||
this.openOrders,
|
||
this.locks,
|
||
this.timers,
|
||
this.pendings,
|
||
desired.side,
|
||
desired.stopPrice,
|
||
Math.abs(this.position.positionAmt),
|
||
Number.isFinite(lastPrice) ? lastPrice : price,
|
||
this.log,
|
||
undefined,
|
||
{ priceTick: this.config.priceTick, qtyStep: this.config.qtyStep }
|
||
);
|
||
if (placed?.orderId != null) {
|
||
state.exchangeStop = {
|
||
orderId: String(placed.orderId),
|
||
side: desired.side,
|
||
stopPrice: desired.stopPrice,
|
||
};
|
||
this.stopPlacedAt = now;
|
||
this.schedulePersist();
|
||
}
|
||
} catch (err) {
|
||
this.log("error", `挂兜底止损单失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 止损层①执行 / 重启
|
||
// -----------------------------------------------------------------------
|
||
|
||
private async haltGrid(reason: string): Promise<void> {
|
||
const state = this.state;
|
||
this.stopReason = reason;
|
||
this.log("warn", `${reason},开始执行撤单与平仓`);
|
||
try {
|
||
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
|
||
this.log("order", "已撤销全部网格挂单");
|
||
} catch (error) {
|
||
this.log("error", `撤销网格挂单失败: ${extractMessage(error)}`);
|
||
}
|
||
if (state) state.exchangeStop = null;
|
||
const qty = this.position.positionAmt;
|
||
if (Math.abs(qty) > EPSILON) {
|
||
const closed = await this.guardedMarketClose(qty > 0 ? "SELL" : "BUY", Math.abs(qty), reason);
|
||
if (!closed) {
|
||
// 滑点守卫暂缓:保持 running,下个 tick 重新触发层①重试
|
||
this.log("warn", "止损平仓被滑点守卫暂缓,下轮重试");
|
||
return;
|
||
}
|
||
}
|
||
this.running = false;
|
||
this.lastUpdated = this.now();
|
||
if (state) {
|
||
for (const level of state.levels) {
|
||
level.phase = "idle";
|
||
level.holdQty = 0;
|
||
delete level.entryOrderId;
|
||
delete level.exitOrderId;
|
||
}
|
||
state.intents.clear();
|
||
state.awaiting.clear();
|
||
state.inflight = null;
|
||
state.shift = null;
|
||
state.prevActiveIds = new Set();
|
||
state.seenOrderIds = new Set();
|
||
state.uncoveredSince = null;
|
||
state.shiftCandidateSince = null;
|
||
}
|
||
this.desiredOrders = [];
|
||
this.uncoveredQty = 0;
|
||
if (!this.skipPersistence) {
|
||
try {
|
||
await clearGridState(this.config.symbol);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
if (!this.config.autoRestart) {
|
||
this.stop();
|
||
}
|
||
}
|
||
|
||
private async tryRestart(): Promise<void> {
|
||
if (!this.config.autoRestart || !this.configValid) return;
|
||
if (!this.isReady()) return;
|
||
if (this.config.restartTriggerPct <= 0) return;
|
||
const price = this.getReferencePrice();
|
||
if (price == null || !Number.isFinite(price)) return;
|
||
const lower = this.state?.lowerPrice ?? this.config.lowerPrice;
|
||
const upper = this.state?.upperPrice ?? this.config.upperPrice;
|
||
const lowerGuard = lower * (1 + this.config.restartTriggerPct);
|
||
const upperGuard = upper * (1 - this.config.restartTriggerPct);
|
||
if (price < lowerGuard || price > upperGuard) return;
|
||
const nextVersion = (this.state?.gridVersion ?? 0) + 1;
|
||
const settings = { ...this.logicSettings(), lowerPrice: lower, upperPrice: upper };
|
||
this.state = createInitialState(settings, price, nextVersion);
|
||
this.running = true;
|
||
this.stopReason = null;
|
||
this.initDone = true;
|
||
this.log("info", `价格重新回到网格区间,恢复网格运行 (gridVersion=${nextVersion})`);
|
||
await this.persistNow();
|
||
this.start();
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Persistence
|
||
// -----------------------------------------------------------------------
|
||
|
||
private schedulePersist(): void {
|
||
if (this.skipPersistence) return;
|
||
if (this.savePending) return;
|
||
this.savePending = true;
|
||
setTimeout(() => {
|
||
this.savePending = false;
|
||
void this.persistNow();
|
||
}, 500);
|
||
}
|
||
|
||
private async persistNow(): Promise<void> {
|
||
if (this.skipPersistence) return;
|
||
const state = this.state;
|
||
if (!state) return;
|
||
try {
|
||
await saveGridState(toStored(state, this.stateMeta(), this.now()));
|
||
} catch (err) {
|
||
this.log("error", `保存网格状态失败: ${extractMessage(err)}`);
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Snapshot
|
||
// -----------------------------------------------------------------------
|
||
|
||
private buildSnapshot(): GridEngineSnapshot {
|
||
const reference = this.getReferencePrice();
|
||
const tickerLast = Number(this.tickerSnapshot?.lastPrice);
|
||
const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference;
|
||
const state = this.state;
|
||
const activeIds = new Set(this.activeGridLimitOrders().map((order) => String(order.orderId)));
|
||
|
||
const gridLines: GridLineSnapshot[] = (state?.levels ?? []).map((level) => ({
|
||
level: level.index,
|
||
price: level.price,
|
||
side: level.entrySide ?? "-",
|
||
role:
|
||
level.entrySide === "BUY" ? "entry-buy" : level.entrySide === "SELL" ? "entry-sell" : "none",
|
||
state: level.phase,
|
||
hasOrder:
|
||
(level.entryOrderId != null && activeIds.has(level.entryOrderId)) ||
|
||
(level.exitOrderId != null && activeIds.has(level.exitOrderId)),
|
||
holdQty: level.holdQty,
|
||
}));
|
||
|
||
return {
|
||
ready: this.isReady() && this.running && this.state != null,
|
||
symbol: this.config.symbol,
|
||
lowerPrice: state?.lowerPrice ?? this.config.lowerPrice,
|
||
upperPrice: state?.upperPrice ?? this.config.upperPrice,
|
||
gridVersion: state?.gridVersion ?? 0,
|
||
anchorPrice: state?.anchorPrice ?? null,
|
||
shiftPhase: state?.shift?.phase ?? null,
|
||
lastPrice,
|
||
midPrice: reference,
|
||
gridLines,
|
||
desiredOrders: this.desiredOrders.slice(),
|
||
openOrders: this.activeGridLimitOrders(),
|
||
position: this.position,
|
||
running: this.running,
|
||
stopReason: this.running ? null : this.stopReason,
|
||
direction: this.config.direction,
|
||
stopProtection: {
|
||
uncoveredQty: this.uncoveredQty,
|
||
exchangeStop: state?.exchangeStop ?? null,
|
||
},
|
||
tradeLog: this.tradeLog.all().slice(),
|
||
feedStatus: { ...this.feedStatus },
|
||
lastUpdated: this.lastUpdated,
|
||
};
|
||
}
|
||
|
||
private emitUpdate(): void {
|
||
this.events.emit("update", this.buildSnapshot());
|
||
}
|
||
}
|