diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index b855858..0513b27 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -128,6 +128,7 @@ export class MakerPointsEngine { private processing = false; private stopLossProcessing = false; private stopLossCooldownUntil = 0; + private forceTickRequested = false; private desiredOrders: DesiredOrder[] = []; private accountUnrealized = 0; private initialOrderSnapshotReady = false; @@ -341,6 +342,10 @@ export class MakerPointsEngine { this.lastStandxDepthTime = Date.now(); this.feedStatus.depth = true; this.emitUpdate(); + if (this.shouldTriggerImmediateDepthProtection(depth)) { + this.forceTickRequested = true; + void this.tick(); + } }, log, { @@ -568,7 +573,9 @@ export class MakerPointsEngine { this.processing = true; let hadRateLimit = false; try { - const decision = this.rateLimit.beforeCycle(); + const forceRun = this.forceTickRequested; + this.forceTickRequested = false; + const decision = forceRun ? "run" : this.rateLimit.beforeCycle(); if (decision === "paused") { this.emitUpdate(); return; @@ -864,6 +871,43 @@ export class MakerPointsEngine { return changed; } + /** + * 当深度从“满足阈值”切换到“不满足阈值”时,立即触发一次主循环,优先撤销不再安全的挂单。 + */ + private shouldTriggerImmediateDepthProtection(depth: AsterDepth | null): boolean { + if (!depth) return false; + if (this.defenseMode || this.reconnectResetPending || this.stopLossProcessing) return false; + + const minDepth = this.config.filterMinDepth; + if (minDepth <= 0) return false; + + const { topBid, topAsk } = getTopPrices(depth); + if (topBid == null || topAsk == null) return false; + + const targets = buildBpsTargets({ + band0To10: this.config.enableBand0To10, + band10To30: this.config.enableBand10To30, + band30To100: this.config.enableBand30To100, + }); + + for (const bps of targets) { + const lastStatus = this.lastDepthOkStatus[bps]; + if (!lastStatus) continue; + + const buyPrice = topBid * (1 - bps / 10000); + const sellPrice = topAsk * (1 + bps / 10000); + const buyDepthQty = getDepthBetweenPrices(depth, "BUY", buyPrice); + const sellDepthQty = getDepthBetweenPrices(depth, "SELL", sellPrice); + const currentBuyOk = buyDepthQty >= minDepth; + const currentSellOk = sellDepthQty >= minDepth; + + if (lastStatus.buy && !currentBuyOk) return true; + if (lastStatus.sell && !currentSellOk) return true; + } + + return false; + } + private buildCloseOnlyOrders( position: PositionSnapshot, bid1: number, diff --git a/tests/maker-points-immediate-depth-protection.test.ts b/tests/maker-points-immediate-depth-protection.test.ts new file mode 100644 index 0000000..37fe5cd --- /dev/null +++ b/tests/maker-points-immediate-depth-protection.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExchangeAdapter } from "../src/exchanges/adapter"; +import type { AsterAccountSnapshot, AsterDepth, AsterKline, AsterOrder, AsterTicker } from "../src/exchanges/types"; +import { MakerPointsEngine } from "../src/strategy/maker-points-engine"; + +class StubAdapter implements ExchangeAdapter { + id = "standx"; + + private depthListeners: Array<(depth: AsterDepth) => void> = []; + + supportsTrailingStops(): boolean { + return false; + } + + watchAccount(_cb: (snapshot: AsterAccountSnapshot) => void): void {} + watchOrders(_cb: (orders: AsterOrder[]) => void): void {} + watchTicker(_symbol: string, _cb: (ticker: AsterTicker) => void): void {} + watchKlines(_symbol: string, _interval: string, _cb: (klines: AsterKline[]) => void): void {} + + watchDepth(_symbol: string, cb: (depth: AsterDepth) => void): void { + this.depthListeners.push(cb); + } + + emitDepth(depth: AsterDepth): void { + for (const listener of this.depthListeners) { + listener(depth); + } + } + + async createOrder(): Promise { + throw new Error("not implemented"); + } + + async cancelOrder(): Promise {} + async cancelOrders(): Promise {} + async cancelAllOrders(): Promise {} + + async queryAccountSnapshot(): Promise { + return null; + } +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("MakerPointsEngine immediate depth protection", () => { + it("triggers an immediate tick when depth drops below threshold", () => { + vi.useFakeTimers(); + const adapter = new StubAdapter(); + + const engine = new MakerPointsEngine( + { + symbol: "BTC-USD", + perOrderAmount: 0.01, + closeThreshold: 0, + stopLossUsd: 1, + refreshIntervalMs: 10_000, + maxLogEntries: 20, + maxCloseSlippagePct: 0.05, + priceTick: 0.1, + qtyStep: 0.001, + enableBand0To10: true, + enableBand10To30: false, + enableBand30To100: false, + band0To10Amount: 0.01, + band10To30Amount: 0.01, + band30To100Amount: 0.01, + minRepriceBps: 3, + enableBinanceDepthCancel: false, + filterMinDepth: 10, + }, + adapter + ); + + (engine as any).feedStatus = { account: true, depth: true, ticker: true, orders: true, binance: true }; + (engine as any).initialOrderSnapshotReady = true; + (engine as any).defenseMode = false; + (engine as any).reconnectResetPending = false; + (engine as any).stopLossProcessing = false; + (engine as any).lastDepthOkStatus[9] = { buy: true, sell: true }; + + const tickSpy = vi.spyOn(engine as any, "tick").mockResolvedValue(undefined); + + adapter.emitDepth({ + lastUpdateId: 1, + bids: [["100", "1"]], + asks: [["101", "1"]], + eventTime: Date.now(), + symbol: "BTC-USD", + }); + + expect(tickSpy).toHaveBeenCalledTimes(1); + engine.stop(); + }); +});