// ============================================================ // PROCESS ORDER FILL // ============================================================ const sd = $getWorkflowStaticData('global'); const orderCtx = $('Build Order Request').first().json; // Always release pendingFill first if this was a skip order if (orderCtx.quantityValue === '0' || orderCtx.symbol === 'SKIP') { sd.pendingFill = null; sd.pendingFillTime = null; return [{ json: { action: orderCtx.action||'hold', orderStatus: 'SKIPPED', trade: orderCtx.tradeContext||{}, btcSignal: orderCtx.btcSignal, portfolio: orderCtx.portfolio, scanSummary: orderCtx.scanSummary, balanceFetchError: orderCtx.balanceFetchError || false, balanceFetchDiag: orderCtx.balanceFetchDiag || '' } }]; } const binanceResp = $input.first().json; const action = orderCtx.action; const tradeCtx = orderCtx.tradeContext || {}; // ── COMPREHENSIVE FAILURE DETECTION ───────────────────────── // Detect ALL failure modes: // 1. Binance API error: { code: -1xxx, msg: '...' } // 2. n8n continueOnFail HTTP error: { error: '...', statusCode: 4xx } // 3. n8n wraps as: { message: '...', name: 'NodeApiError', httpCode: '4xx' } // 4. Empty response (no orderId, no status) — order did NOT go through // 5. Non-FILLED status const isBinanceError = binanceResp.code && Number(binanceResp.code) < 0; const isHttpError = binanceResp.statusCode && Number(binanceResp.statusCode) >= 400; const isNodeError = binanceResp.name === 'NodeApiError' || binanceResp.name === 'NodeOperationError'; const hasOrderId = !!binanceResp.orderId; const isFilled = binanceResp.status === 'FILLED' || binanceResp.status === 'PARTIALLY_FILLED'; const isMissingFill = !hasOrderId || !isFilled; // Any of these conditions = order did NOT reach Binance or was rejected const orderFailed = isBinanceError || isHttpError || isNodeError || isMissingFill; if (orderFailed) { sd.pendingFill = null; // always release guard — never leave bot stuck sd.pendingFillTime = null; // Build a diagnostic error message to see what Binance actually returned let errCode = binanceResp.code || binanceResp.statusCode || binanceResp.httpCode || '?'; let errMsg = binanceResp.msg || (typeof binanceResp.message === 'string' ? binanceResp.message : '') || (typeof binanceResp.error === 'string' ? binanceResp.error : ''); if (!errMsg) errMsg = JSON.stringify(binanceResp).slice(0, 400); const diagMsg = 'ORDER FAILED [' + errCode + ']: ' + errMsg + ' | sym=' + (orderCtx.symbol||'?') + ' side=' + (orderCtx.orderSide||'?') + ' qty=' + (orderCtx.quantityValue||'?'); return [{ json: { action, orderStatus: 'ERROR', binanceError: { code: errCode, msg: errMsg }, trade: { ...tradeCtx, symbol: tradeCtx.symbol || orderCtx.symbol }, btcSignal: orderCtx.btcSignal, portfolio: orderCtx.portfolio, scanSummary: orderCtx.scanSummary, message: diagMsg } }]; } const fills = Array.isArray(binanceResp.fills) ? binanceResp.fills : []; const executedQty = parseFloat(binanceResp.executedQty || 0); const cummulativeQuote = parseFloat(binanceResp.cummulativeQuoteQty || 0); const orderStatus = binanceResp.status; const orderId = binanceResp.orderId; const symbol = binanceResp.symbol || orderCtx.symbol; const side = orderCtx.orderSide; let avgFillPrice = 0; if (fills.length > 0) { const tv = fills.reduce((s, f) => s + parseFloat(f.qty||0) * parseFloat(f.price||0), 0); const tq = fills.reduce((s, f) => s + parseFloat(f.qty||0), 0); avgFillPrice = tq > 0 ? tv / tq : 0; } else if (executedQty > 0) { avgFillPrice = cummulativeQuote / executedQty; } // Safety: if avgFillPrice is still 0 after a real fill, something is very wrong if (avgFillPrice <= 0) { sd.pendingFill = null; sd.pendingFillTime = null; return [{ json: { action, orderStatus: 'ERROR', binanceError: { code: '?', msg: 'avgFillPrice=0 despite FILLED status' }, trade: { ...tradeCtx, symbol: tradeCtx.symbol || orderCtx.symbol }, btcSignal: orderCtx.btcSignal, portfolio: orderCtx.portfolio, scanSummary: orderCtx.scanSummary, message: 'ORDER PRICE ERROR: fill price was 0 — orderId=' + orderId + ' status=' + orderStatus } }]; } const totalCommission = fills.reduce((s, f) => s + parseFloat(f.commission||0), 0); const commissionAsset = fills.length > 0 ? fills[0].commissionAsset : 'BNB'; let enrichedTrade = { ...tradeCtx }; if (side === 'BUY') { // Real fill confirmed — safe to set position const realQty = executedQty > 0 ? executedQty : cummulativeQuote / avgFillPrice; const tradeValue = cummulativeQuote > 0 ? cummulativeQuote : realQty * avgFillPrice; const fillEntry = avgFillPrice; // tpPct/slPct from openPos() are in % (e.g. 2.0 means 2%) — convert to decimal for position tracking const tpDecimal = tradeCtx.tpPct ? tradeCtx.tpPct / 100 : 0.02; const slDecimal = tradeCtx.slPct ? tradeCtx.slPct / 100 : 0.01; sd.position = { symbol: symbol, entry: fillEntry, qty: realQty, tradeValue, tpPct: tpDecimal, slPct: slDecimal, highWater: fillEntry, entryTime: new Date().toISOString(), entryScore: tradeCtx.score || 0 }; sd.todayTrades = (sd.todayTrades || 0) + 1; sd.pendingFill = null; // release the re-entry guard — order filled successfully sd.pendingEntry = null; // clear any pending switch entry — we're now in position if (!sd.tradeHistory) sd.tradeHistory = []; sd.tradeHistory.unshift({ t: new Date().toISOString(), a: 'BUY', sym: symbol, price: +fillEntry.toFixed(6), qty: +realQty.toFixed(6), spent: +tradeValue.toFixed(2) }); if (sd.tradeHistory.length > 50) sd.tradeHistory.length = 50; enrichedTrade = { ...tradeCtx, symbol, realFillPrice: +fillEntry.toFixed(8), realQty: +realQty.toFixed(6), realQuoteSpent: +tradeValue.toFixed(4), realCommission: +totalCommission.toFixed(8), commissionAsset, orderId, orderStatus }; } else if (side === 'SELL') { const entryPrice = (sd.position && sd.position.entry) || tradeCtx.entryPrice || 0; const heldQty = (sd.position && sd.position.qty) || tradeCtx.qty || executedQty || 0; const fillPrice = avgFillPrice > 0 ? avgFillPrice : tradeCtx.exitPrice || entryPrice; const grossPnl = (fillPrice - entryPrice) * heldQty; const entryFee = entryPrice * heldQty * 0.001; const exitFee = fillPrice * heldQty * 0.001; const netPnl = grossPnl - entryFee - exitFee; const pnlPct = entryPrice > 0 ? (fillPrice - entryPrice) / entryPrice * 100 : 0; const holdMins = sd.position && sd.position.entryTime ? +((Date.now() - new Date(sd.position.entryTime).getTime()) / 60000).toFixed(1) : (tradeCtx.holdMins || 0); if (sd.capital !== undefined) sd.capital = (sd.capital || 300) + netPnl; sd.pnl = (sd.pnl || 0) + netPnl; sd.trades = (sd.trades || 0) + 1; if (netPnl >= 0) sd.wins = (sd.wins || 0) + 1; else { sd.losses = (sd.losses || 0) + 1; sd.lastLossTime = Date.now(); } if (!sd.tradeHistory) sd.tradeHistory = []; sd.tradeHistory.unshift({ t: new Date().toISOString(), a: action === 'close_tp' ? 'TP' : action === 'close_sl' ? 'SL' : 'SW', sym: symbol, price: +fillPrice.toFixed(6), qty: +heldQty.toFixed(6), pnl: +netPnl.toFixed(4), pct: +pnlPct.toFixed(3), mins: holdMins }); if (sd.tradeHistory.length > 50) sd.tradeHistory.length = 50; if (!sd.lastExitBySymbol || typeof sd.lastExitBySymbol !== 'object' || Array.isArray(sd.lastExitBySymbol)) sd.lastExitBySymbol = {}; const exitedSymbol = (symbol || tradeCtx.symbol || '').toString().trim().toUpperCase(); if (exitedSymbol) sd.lastExitBySymbol[exitedSymbol] = Date.now(); sd.position = null; // Note: if action==='close_switch', sd.pendingEntry is preserved (set by Decision Engine) enrichedTrade = { ...tradeCtx, symbol, realFillPrice: +fillPrice.toFixed(8), realQty: +heldQty.toFixed(6), realNetPnl: +netPnl.toFixed(4), realPnlPct: +pnlPct.toFixed(3), realFees: +(entryFee + exitFee).toFixed(4), realCommission: +totalCommission.toFixed(8), commissionAsset, holdMins, orderId, orderStatus, capital: +(sd.capital||0).toFixed(2), totalPnl: +(sd.pnl||0).toFixed(2), wins: sd.wins || 0, losses: sd.losses || 0, winRate: +((sd.trades||0) > 0 ? (sd.wins||0) / (sd.trades||0) * 100 : 0).toFixed(1) }; } return [{ json: { action, orderStatus, trade: enrichedTrade, btcSignal: orderCtx.btcSignal, portfolio: orderCtx.portfolio, scanSummary: orderCtx.scanSummary } }];