From d8941c5ad01b5351d1f4fade239449a215d9b103 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 6 May 2026 16:39:13 +0000 Subject: [PATCH] fix(api): auto-close dust exit remainders --- backend/src/services/TradeExecutor.ts | 76 ++++++++++++++++++++------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/backend/src/services/TradeExecutor.ts b/backend/src/services/TradeExecutor.ts index 3623555..aeb9b20 100644 --- a/backend/src/services/TradeExecutor.ts +++ b/backend/src/services/TradeExecutor.ts @@ -128,6 +128,35 @@ export class TradeExecutor { } return 0.0001; } + + private getDustNotionalThresholdUsd(): number { + const configured = Number(config.MIN_NOTIONAL_USD || 10); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + return 10; + } + + private isDustRemainder(qty: number, referencePrice?: number): boolean { + const normalizedQty = Number(qty); + if (!(Number.isFinite(normalizedQty) && normalizedQty > 0)) { + return true; + } + + if (normalizedQty <= this.getDustQtyThreshold()) { + return true; + } + + const normalizedPrice = Number(referencePrice); + if (Number.isFinite(normalizedPrice) && normalizedPrice > 0) { + const remainingNotionalUsd = normalizedQty * normalizedPrice; + if (remainingNotionalUsd <= this.getDustNotionalThresholdUsd()) { + return true; + } + } + + return false; + } constructor( private exchange: IExchangeConnector, @@ -1642,13 +1671,14 @@ export class TradeExecutor { // We use a local variable for the order volume. } else { const dustThreshold = this.getDustQtyThreshold(); - if (pos.size <= dustThreshold) { - const fallbackExitPrice = Number.isFinite(Number(currentPriceHint)) && Number(currentPriceHint) > 0 - ? Number(currentPriceHint) - : (this.apiServer?.getState?.().symbols?.[symbol]?.price || pos.entryPrice); - logger.warn(`[Hardening] Auto-finalizing dust remainder for ${symbol}: local=${pos.size}, exchange=${exchangeQty}, threshold=${dustThreshold}.`); + const fallbackExitPrice = Number.isFinite(Number(currentPriceHint)) && Number(currentPriceHint) > 0 + ? Number(currentPriceHint) + : (this.apiServer?.getState?.().symbols?.[symbol]?.price || pos.entryPrice); + if (this.isDustRemainder(pos.size, fallbackExitPrice)) { + const dustNotionalThreshold = this.getDustNotionalThresholdUsd(); + logger.warn(`[Hardening] Auto-finalizing dust remainder for ${symbol}: local=${pos.size}, exchange=${exchangeQty}, qty_threshold=${dustThreshold}, notional_threshold_usd=${dustNotionalThreshold}.`); await this.finalizeTrade(symbol, fallbackExitPrice, `${reason} (dust auto-close)`, positionTradeId); - this.setExitLifecycle(symbol, 'filled', reason, `dust_autoclose_threshold=${dustThreshold}`); + this.setExitLifecycle(symbol, 'filled', reason, `dust_autoclose_qty=${dustThreshold};dust_autoclose_notional_usd=${dustNotionalThreshold}`); return { success: true, exitPrice: fallbackExitPrice }; } logger.error(`[Guardrail] Aborting SELL Exit for ${symbol}: Insufficient exchange balance (Requested: ${pos.size}, Exchange: ${exchangeQty}).`); @@ -1889,20 +1919,26 @@ export class TradeExecutor { }; } - const appliedQty = Math.min(requestedFillQty, pos.size); - const remainingSize = Math.max(0, Number((pos.size - appliedQty).toFixed(8))); - const resolvedExitPrice = Number.isFinite(exitPrice) && exitPrice > 0 ? exitPrice : pos.entryPrice; - - // Fully closed lifecycle: finalize and clear local position state. - if (remainingSize <= 1e-8) { - await this.finalizeTrade(symbol, resolvedExitPrice, reason, selectedTradeId); - return { - success: true, - fullyClosed: true, - appliedQty, - remainingSize: 0 - }; - } + const appliedQty = Math.min(requestedFillQty, pos.size); + const remainingSize = Math.max(0, Number((pos.size - appliedQty).toFixed(8))); + const resolvedExitPrice = Number.isFinite(exitPrice) && exitPrice > 0 ? exitPrice : pos.entryPrice; + const dustQtyThreshold = this.getDustQtyThreshold(); + const dustNotionalThreshold = this.getDustNotionalThresholdUsd(); + const shouldAutoCloseDustRemainder = this.isDustRemainder(remainingSize, resolvedExitPrice); + + // Fully closed lifecycle: finalize and clear local position state. + if (remainingSize <= 1e-8 || shouldAutoCloseDustRemainder) { + if (remainingSize > 1e-8) { + logger.warn(`[Hardening] Auto-finalizing post-exit dust remainder for ${symbol}: remaining=${remainingSize}, qty_threshold=${dustQtyThreshold}, notional_threshold_usd=${dustNotionalThreshold}, price=${resolvedExitPrice}.`); + } + await this.finalizeTrade(symbol, resolvedExitPrice, reason, selectedTradeId); + return { + success: true, + fullyClosed: true, + appliedQty, + remainingSize: 0 + }; + } // Partial exit lifecycle: persist realized slice and keep monitoring remainder. const pnl = (resolvedExitPrice - pos.entryPrice) * appliedQty * (pos.side === SignalDirection.BUY ? 1 : -1);