diff --git a/src/core/order-coordinator.ts b/src/core/order-coordinator.ts index 25b8464..8a457c6 100644 --- a/src/core/order-coordinator.ts +++ b/src/core/order-coordinator.ts @@ -384,7 +384,13 @@ export async function marketClose( const qtyStep = opts?.qtyStep; const rawQuantity = Math.abs(quantity); const normalizedQtyRaw = qtyStep != null ? roundQtyDownToStep(rawQuantity, qtyStep) : rawQuantity; - const normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity; + let normalizedQty = normalizedQtyRaw > 0 ? normalizedQtyRaw : rawQuantity; + if (qtyStep != null) { + const epsilon = Math.max(qtyStep * 1e-4, 1e-10); + if (Math.abs(rawQuantity - normalizedQty) <= epsilon) { + normalizedQty = rawQuantity; + } + } if (normalizedQty <= 0) { log("error", "市价平仓数量无效,跳过下单"); return; diff --git a/src/exchanges/adapter.ts b/src/exchanges/adapter.ts index 4c877a6..6368db3 100644 --- a/src/exchanges/adapter.ts +++ b/src/exchanges/adapter.ts @@ -27,6 +27,14 @@ export interface KlineListener { (klines: AsterKline[]): void; } +export interface ExchangePrecision { + priceTick: number; + qtyStep: number; + priceDecimals?: number; + sizeDecimals?: number; + marketId?: number; +} + export interface ExchangeAdapter { readonly id: string; supportsTrailingStops(): boolean; @@ -39,4 +47,5 @@ export interface ExchangeAdapter { cancelOrder(params: { symbol: string; orderId: number | string }): Promise; cancelOrders(params: { symbol: string; orderIdList: Array }): Promise; cancelAllOrders(params: { symbol: string }): Promise; + getPrecision?(): Promise; } diff --git a/src/exchanges/lighter/adapter.ts b/src/exchanges/lighter/adapter.ts index e00fe27..c521790 100644 --- a/src/exchanges/lighter/adapter.ts +++ b/src/exchanges/lighter/adapter.ts @@ -2,6 +2,7 @@ import type { AccountListener, DepthListener, ExchangeAdapter, + ExchangePrecision, KlineListener, OrderListener, TickerListener, @@ -124,6 +125,22 @@ export class LighterExchangeAdapter implements ExchangeAdapter { await this.gateway.cancelAllOrders(); } + async getPrecision(): Promise { + try { + const precision = await this.gateway.getPrecision(); + return { + priceTick: precision.priceTick, + qtyStep: precision.qtyStep, + priceDecimals: precision.priceDecimals, + sizeDecimals: precision.sizeDecimals, + marketId: precision.marketId ?? undefined, + }; + } catch (error) { + this.logError("precision", error); + return null; + } + } + private ensureInitialized(context?: string): Promise { if (!this.initPromise) { this.initContexts.clear(); diff --git a/src/exchanges/lighter/gateway.ts b/src/exchanges/lighter/gateway.ts index e74967c..48c90e8 100644 --- a/src/exchanges/lighter/gateway.ts +++ b/src/exchanges/lighter/gateway.ts @@ -116,6 +116,8 @@ interface Pollers { const KLINE_DEFAULT_COUNT = 120; const DEFAULT_TICKER_POLL_MS = 3000; const DEFAULT_KLINE_POLL_MS = 15000; +const WS_HEARTBEAT_INTERVAL_MS = 5_000; +const WS_STALE_TIMEOUT_MS = 20_000; const RESOLUTION_MS: Record = { "1m": 60_000, @@ -172,6 +174,8 @@ export class LighterGateway { private reconnectTimer: ReturnType | null = null; private readonly wsUrl: string; private connectPromise: Promise | null = null; + private heartbeatTimer: ReturnType | null = null; + private lastMessageAt = 0; private accountDetails: LighterAccountDetails | null = null; private positions: LighterPosition[] = []; @@ -438,25 +442,56 @@ export class LighterGateway { await new Promise((resolve, reject) => { const ws = new WebSocket(this.wsUrl); this.ws = ws; + let settled = false; const cleanup = () => { ws.removeAllListeners(); + this.stopHeartbeat(); + if (this.ws === ws) { + this.ws = null; + } + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : new Error(String(error))); }; ws.on("open", async () => { try { + this.lastMessageAt = Date.now(); + this.startHeartbeat(); await this.subscribeChannels(); + settled = true; resolve(); } catch (error) { - reject(error); + cleanup(); + fail(error); + return; } }); - ws.on("message", (data) => this.handleMessage(data)); + ws.on("message", (data) => { + this.lastMessageAt = Date.now(); + this.handleMessage(data); + }); + ws.on("pong", () => { + this.lastMessageAt = Date.now(); + }); ws.on("close", (code, reason) => { cleanup(); + const normalizedReason = typeof reason === "string" && reason.length ? reason : undefined; + if (!settled) { + fail(new Error(`WebSocket closed before ready (code=${code}${normalizedReason ? `, reason=${normalizedReason}` : ""})`)); + return; + } this.scheduleReconnect(); }); ws.on("error", (error) => { - cleanup(); this.logger("ws:error", error); + cleanup(); + if (!settled) { + fail(error); + return; + } + this.scheduleReconnect(); }); }); } @@ -506,6 +541,38 @@ export class LighterGateway { }, 2000); } + private startHeartbeat(): void { + if (this.heartbeatTimer) return; + this.heartbeatTimer = setInterval(() => { + const ws = this.ws; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const now = Date.now(); + if (now - this.lastMessageAt > WS_STALE_TIMEOUT_MS) { + try { + ws.terminate(); + } catch (error) { + this.logger("ws:terminate", error); + } finally { + this.stopHeartbeat(); + this.scheduleReconnect(); + } + return; + } + try { + ws.ping(); + } catch (error) { + this.logger("ws:ping", error); + } + }, WS_HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + private handleMessage(data: WebSocket.RawData): void { try { const text = typeof data === "string" ? data : data.toString("utf8"); @@ -785,6 +852,28 @@ export class LighterGateway { this.tickerEvent.emit(ticker); } + async getPrecision(): Promise<{ + priceTick: number; + qtyStep: number; + priceDecimals: number; + sizeDecimals: number; + marketId: number | null; + }> { + await this.loadMetadata(); + if (this.priceDecimals == null || this.sizeDecimals == null) { + throw new Error("Lighter market metadata not initialized"); + } + const priceTick = decimalsToStep(this.priceDecimals); + const qtyStep = decimalsToStep(this.sizeDecimals); + return { + priceTick, + qtyStep, + priceDecimals: this.priceDecimals, + sizeDecimals: this.sizeDecimals, + marketId: this.marketId ?? null, + }; + } + private mapCreateOrderParams(params: CreateOrderParams): Omit & { baseAmountScaledString: string; priceScaledString: string; @@ -953,3 +1042,11 @@ function mapTimeInForce(timeInForce: string | undefined, type: OrderType): numbe return LIGHTER_TIME_IN_FORCE.GOOD_TILL_TIME; } } + +function decimalsToStep(decimals: number): number { + if (!Number.isFinite(decimals) || decimals <= 0) { + return 1; + } + const step = Number(`1e-${decimals}`); + return Number.isFinite(step) ? step : Math.pow(10, -decimals); +} diff --git a/src/strategy/grid-engine.ts b/src/strategy/grid-engine.ts index 3dd5eeb..9a328ee 100644 --- a/src/strategy/grid-engine.ts +++ b/src/strategy/grid-engine.ts @@ -80,7 +80,7 @@ export class GridEngine { private readonly locks: OrderLockMap = {}; private readonly timers: OrderTimerMap = {}; private readonly pendings: OrderPendingMap = {}; - private readonly priceDecimals: number; + private priceDecimals: number; private readonly now: () => number; private readonly configValid: boolean; private readonly gridLevels: number[]; @@ -134,6 +134,7 @@ export class GridEngine { }; private readonly log: LogHandler; + private precisionSync: Promise | null = null; private timer: ReturnType | null = null; private processing = false; @@ -155,6 +156,7 @@ export class GridEngine { this.configValid = this.validateConfig(); this.gridLevels = this.computeGridLevels(); this.buildLevelMeta(); + this.syncPrecision(); this.running = this.configValid; if (!this.configValid) { this.stopReason = "配置无效,已暂停网格"; @@ -200,6 +202,52 @@ export class GridEngine { return this.buildSnapshot(); } + 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; + this.priceDecimals = decimalsOf(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}` + ); + this.rebuildGridAfterPrecisionUpdate(); + } + }) + .catch((error) => { + this.log("error", `同步精度失败: ${extractMessage(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + + private rebuildGridAfterPrecisionUpdate(): void { + if (!this.configValid) return; + const reference = this.getReferencePrice(); + const newLevels = this.computeGridLevels(); + this.gridLevels.length = 0; + this.gridLevels.push(...newLevels); + this.buildLevelMeta(reference); + this.emitUpdate(); + } + private validateConfig(): boolean { if (this.config.lowerPrice <= 0 || this.config.upperPrice <= 0) { return false; diff --git a/src/strategy/maker-engine.ts b/src/strategy/maker-engine.ts index 9335c3b..bbd99cc 100644 --- a/src/strategy/maker-engine.ts +++ b/src/strategy/maker-engine.ts @@ -77,6 +77,9 @@ export class MakerEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); private readonly sessionVolume = new SessionVolumeTracker(); + private priceTick: number = 0.1; + private qtyStep: number = 0.001; + private precisionSync: Promise | null = null; private timer: ReturnType | null = null; private processing = false; @@ -114,6 +117,9 @@ export class MakerEngine { this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.tradeLog.push(type, detail) ); + this.priceTick = Math.max(1e-9, this.config.priceTick); + this.qtyStep = Math.max(1e-9, this.qtyStep); + this.syncPrecision(); this.bootstrap(); } @@ -290,7 +296,7 @@ export class MakerEngine { } // 直接使用orderbook价格,格式化为字符串避免精度问题 - const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); + const priceDecimals = this.getPriceDecimals(); const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals); const bidPrice = formatPriceToString(topBid - this.config.bidOffset, priceDecimals); @@ -340,7 +346,7 @@ export class MakerEngine { if (Math.abs(position.positionAmt) < EPS) return; const { topBid, topAsk } = getTopPrices(this.depthSnapshot); if (topBid == null || topAsk == null) return; - const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); + const priceDecimals = this.getPriceDecimals(); const closeBidPrice = formatPriceToString(topBid, priceDecimals); const closeAskPrice = formatPriceToString(topAsk, priceDecimals); await this.checkRisk(position, Number(closeBidPrice), Number(closeAskPrice)); @@ -431,8 +437,8 @@ export class MakerEngine { maxPct: this.config.maxCloseSlippagePct, }, { - priceTick: this.config.priceTick, - qtyStep: 0.001, // 默认数量步长 + priceTick: this.priceTick, + qtyStep: this.qtyStep, } ); } catch (error) { @@ -527,6 +533,48 @@ export class MakerEngine { } } + 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.priceTick) > 1e-12) { + this.priceTick = precision.priceTick; + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) { + this.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + + private getPriceDecimals(): number { + const tick = Math.max(1e-9, this.priceTick); + const raw = Math.log10(1 / tick); + if (!Number.isFinite(raw)) return 0; + return Math.max(0, Math.floor(raw + 1e-9)); + } + private emitUpdate(): void { try { const snapshot = this.buildSnapshot(); diff --git a/src/strategy/offset-maker-engine.ts b/src/strategy/offset-maker-engine.ts index efeefd9..6e6c398 100644 --- a/src/strategy/offset-maker-engine.ts +++ b/src/strategy/offset-maker-engine.ts @@ -64,6 +64,9 @@ export class OffsetMakerEngine { private readonly tradeLog: ReturnType; private readonly events = new StrategyEventEmitter(); private readonly sessionVolume = new SessionVolumeTracker(); + private priceTick: number = 0.1; + private qtyStep: number = 0.001; + private precisionSync: Promise | null = null; private timer: ReturnType | null = null; private processing = false; @@ -93,6 +96,9 @@ export class OffsetMakerEngine { this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => this.tradeLog.push(type, detail) ); + this.priceTick = Math.max(1e-9, this.config.priceTick); + this.qtyStep = Math.max(1e-9, this.qtyStep); + this.syncPrecision(); // Debounce window defaults to 3x refresh interval, min 1s this.repriceDwellMs = Math.max(1000, this.config.refreshIntervalMs * 3); this.bootstrap(); @@ -275,7 +281,7 @@ export class OffsetMakerEngine { const finalAsk = latestAsk ?? topAsk!; // 直接使用orderbook价格,格式化为字符串避免精度问题 - const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); + const priceDecimals = this.getPriceDecimals(); const closeBidPrice = formatPriceToString(finalBid, priceDecimals); const closeAskPrice = formatPriceToString(finalAsk, priceDecimals); const bidPrice = formatPriceToString(finalBid - this.config.bidOffset, priceDecimals); @@ -326,7 +332,7 @@ export class OffsetMakerEngine { const absPosition = Math.abs(position.positionAmt); const side: "BUY" | "SELL" = position.positionAmt > 0 ? "SELL" : "BUY"; const { topBid, topAsk } = getTopPrices(this.depthSnapshot); - const priceDecimals = Math.max(0, Math.floor(Math.log10(1 / this.config.priceTick))); + const priceDecimals = this.getPriceDecimals(); const closeBidPrice = topBid != null ? formatPriceToString(topBid, priceDecimals) : null; const closeAskPrice = topAsk != null ? formatPriceToString(topAsk, priceDecimals) : null; try { @@ -464,7 +470,7 @@ export class OffsetMakerEngine { const newPrice = Number(t.price); const oldPrice = Number(existing.price); if (!Number.isFinite(newPrice) || !Number.isFinite(oldPrice)) continue; - const ticksDiff = Math.abs(newPrice - oldPrice) / this.config.priceTick; + const ticksDiff = Math.abs(newPrice - oldPrice) / this.priceTick; const recentPlaced = this.lastEntryOrderBySide[t.side]?.ts ?? 0; const withinDwell = Date.now() - recentPlaced < this.repriceDwellMs; if (ticksDiff < this.minRepriceTicks || withinDwell) { @@ -529,8 +535,8 @@ export class OffsetMakerEngine { maxPct: this.config.maxCloseSlippagePct, }, { - priceTick: this.config.priceTick, - qtyStep: 0.001, // 默认数量步长 + priceTick: this.priceTick, + qtyStep: this.qtyStep, } ); // Record last placed entry order timing and price @@ -620,6 +626,48 @@ export class OffsetMakerEngine { } } + 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.priceTick) > 1e-12) { + this.priceTick = precision.priceTick; + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) { + this.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", `同步精度失败: ${String(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + + private getPriceDecimals(): number { + const tick = Math.max(1e-9, this.priceTick); + const raw = Math.log10(1 / tick); + if (!Number.isFinite(raw)) return 0; + return Math.max(0, Math.floor(raw + 1e-9)); + } + private emitUpdate(): void { try { const snapshot = this.buildSnapshot(); diff --git a/src/strategy/trend-engine.ts b/src/strategy/trend-engine.ts index 3364d11..32134f2 100644 --- a/src/strategy/trend-engine.ts +++ b/src/strategy/trend-engine.ts @@ -123,12 +123,14 @@ export class TrendEngine { .digest("hex"); private readonly listeners = new Map>(); + private precisionSync: Promise | null = null; constructor(private readonly config: TradingConfig, private readonly exchange: ExchangeAdapter) { this.tradeLog = createTradeLog(this.config.maxLogEntries); this.rateLimit = new RateLimitController(this.config.pollIntervalMs, (type, detail) => this.tradeLog.push(type, detail) ); + this.syncPrecision(); this.bootstrap(); } @@ -929,6 +931,42 @@ export class TrendEngine { } } + 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) { + const delta = Math.abs(precision.priceTick - this.config.priceTick); + if (delta > 1e-12) { + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + const delta = Math.abs(precision.qtyStep - this.config.qtyStep); + if (delta > 1e-12) { + this.config.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + `已同步交易精度: priceTick=${precision.priceTick} qtyStep=${precision.qtyStep}` + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", `同步精度失败: ${extractMessage(error)}`); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + private emitUpdate(): void { try { const snapshot = this.buildSnapshot();