// ============================================================ // DECISION ENGINE // ============================================================ // ── CONFIG ─────────────────────────────────────────────────── const POSITION_PCT = 0.50; // 50% of real USDT balance per trade const TAKER_FEE = 0.001; // 0.1% Binance taker fee const MAX_TRADES_DAY = Infinity; // no daily trade limit — bot runs 24/7 const COOLDOWN_MIN = 1; // minutes after a loss before re-entry const COIN_COOLDOWN_MIN = 10; // minutes after exit before re-entering same coin const TRAIL_TRIGGER = 0.003; // 0.3% gain before trailing activates const TRAIL_OFFSET = 0.002; // 0.2% drop from peak triggers exit const MAX_HOLD_MINS = 60; // max time in position const MIN_CAPITAL = 10; const REAL_CAPITAL = 285; // used only on first run to seed sd.capital const INITIAL_POSITION = null; // set if already holding: { symbol, entry, qty, tpPct, slPct, entryTime } const SWITCH_DELTA = 15; // score gap needed to switch const SWITCH_MIN_HOLD = 3 * 60000; const SWITCH_COOLDOWN = 5 * 60000; const LIVE_TRADING = true; const BTC_BEAR_ROC5_EMERGENCY = -2.0; // BTC drop > 2% in 5 candles = emergency exit only // ── INDICATOR HELPERS ──────────────────────────────────────── function ema(data, period) { if (data.length < period) return data[data.length - 1] || 0; const k = 2 / (period + 1); let e = data.slice(0, period).reduce((s, v) => s + v, 0) / period; for (let i = period; i < data.length; i++) e = data[i] * k + e * (1 - k); return e; } function rsiCalc(closes, period) { if (closes.length < period + 1) return 50; const slice = closes.slice(-(period + 1)); let g = 0, l = 0; for (let i = 1; i < slice.length; i++) { const d = slice[i] - slice[i - 1]; d >= 0 ? (g += d) : (l -= d); } const ag = g / period, al = l / period; return al === 0 ? 100 : 100 - 100 / (1 + ag / al); } function calcATR(highs, lows, closes, period) { const trs = []; for (let i = 1; i < highs.length; i++) { trs.push(Math.max( highs[i] - lows[i], Math.abs(highs[i] - closes[i - 1]), Math.abs(lows[i] - closes[i - 1]) )); } const sl = trs.slice(-period); return sl.length ? sl.reduce((s, v) => s + v, 0) / sl.length : 0; } function lrSlope(values) { const n = values.length; const xm = (n - 1) / 2; const ym = values.reduce((s, v) => s + v, 0) / n; let num = 0, den = 0; for (let i = 0; i < n; i++) { num += (i - xm) * (values[i] - ym); den += (i - xm) ** 2; } return den > 0 ? num / den : 0; } // ── READ INPUTS ────────────────────────────────────────────── const ctx = $('Pick Best Candidate').first().json; const best = ctx.bestCandidate || {}; const bestSymbol = ctx.bestSymbol || 'BTCUSDT'; const heldCurrentPrice = ctx.heldCurrentPrice || 0; const btcNode = $('Analyze BTC Trend').first().json; const btcSignal = btcNode.btcSignal || { trend: 'neutral', score: 0, roc5: 0, dataOk: false }; const btcBear = btcSignal.trend === 'bear' && btcSignal.score <= -1; const btcBull = btcSignal.trend === 'bull' && btcSignal.score >= 1; const btcNeutral = !btcBear && !btcBull; // Handle both: single item with full kline array, and n8n splitting into individual candle items const allKlineItems = $input.all(); let rawKlines; if (allKlineItems.length > 1) { // n8n split — each item.json is one candle [ts, open, high, low, close, vol, ...] rawKlines = allKlineItems.map(item => { const v = item.json; // n8n may wrap array as { 0: ts, 1: open, ... } object return Array.isArray(v) ? v : Object.values(v); }); } else { const rawInput = allKlineItems[0].json; if (Array.isArray(rawInput) && Array.isArray(rawInput[0])) { rawKlines = rawInput; // [[ts,o,h,l,c,v], ...] — full array as single item } else if (Array.isArray(rawInput)) { // Might be a single candle returned — wrap it rawKlines = rawInput.length > 0 && !Array.isArray(rawInput[0]) ? [rawInput] : rawInput; } else { rawKlines = []; } } let klineIndicators = null; if (rawKlines.length >= 20) { const highs = rawKlines.map(k => parseFloat(k[2])); const lows = rawKlines.map(k => parseFloat(k[3])); const closes = rawKlines.map(k => parseFloat(k[4])); const volumes = rawKlines.map(k => parseFloat(k[5])); const n = closes.length; const price = closes[n - 1]; const recentVol = volumes.slice(-5).reduce((s, v) => s + v, 0) / 5; const baselineVol = volumes.slice(-15, -5).reduce((s, v) => s + v, 0) / 10; const volAccel = baselineVol > 0 ? recentVol / baselineVol : 1; const ATR = calcATR(highs, lows, closes, 14); const RSI = rsiCalc(closes, 14); const EMA9 = ema(closes, 9); const EMA21 = ema(closes, 21); const tpCalc = Math.max(price > 0 ? (ATR * 2.0) / price : 0.02, 0.015); const slCalc = Math.max(price > 0 ? (ATR * 1.0) / price : 0.01, 0.008); klineIndicators = { rsi: +RSI.toFixed(1), ema9: +EMA9.toFixed(8), ema21: +EMA21.toFixed(8), volAccel: +volAccel.toFixed(2), atr: +ATR.toFixed(8), tpPct: +tpCalc.toFixed(6), slPct: +slCalc.toFixed(6) }; } const tpPct = klineIndicators ? klineIndicators.tpPct : (ctx.tpPct || 0.02); const slPct = klineIndicators ? klineIndicators.slPct : (ctx.slPct || 0.01); // ── ENTRY VERIFICATION — only hard block on extreme RSI ────── let entryConfirmed = true; // default: allow entry let entryChecks = { pass: 5 }; if (klineIndicators) { const rsi = klineIndicators.rsi; const rsiHardBlock = rsi > 82 || rsi < 20; // only block truly extreme RSI const cls = rawKlines.map(k => parseFloat(k[4])); const opns = rawKlines.map(k => parseFloat(k[1])); const highs2 = rawKlines.map(k => parseFloat(k[2])); const n2 = cls.length; const slope = lrSlope(cls.slice(-10)); const body = Math.abs(cls[n2-1] - opns[n2-1]); const upperWick = highs2[n2-1] - Math.max(cls[n2-1], opns[n2-1]); const rsiOk = rsi >= 30 && rsi <= 78; const emaTrendOk = klineIndicators.ema9 > klineIndicators.ema21; const slopeOk = slope > 0; const volOk = klineIndicators.volAccel >= 0.8; const noReversal = body === 0 || upperWick < body * 4.0; const pass = [rsiOk, emaTrendOk, slopeOk, volOk, noReversal].filter(Boolean).length; entryConfirmed = !rsiHardBlock; // only block if RSI is extreme entryChecks = { rsiOk, emaTrendOk, slopeOk, volOk, noReversal, pass, rsiHardBlock, rsi, slope: +slope.toFixed(8) }; } // ── STATE ──────────────────────────────────────────────────── const sd = $getWorkflowStaticData('global'); // Force-correct capital if it looks like stale test data (> 10x REAL_CAPITAL) if (sd.capital === undefined || sd.capital > REAL_CAPITAL * 5) sd.capital = REAL_CAPITAL; if (sd.trades === undefined) sd.trades = 0; if (sd.todayTrades === undefined) sd.todayTrades = 0; if (sd.todayDate === undefined) sd.todayDate = ''; if (sd.wins === undefined) sd.wins = 0; if (sd.losses === undefined) sd.losses = 0; if (sd.pnl === undefined) sd.pnl = 0; if (sd.lastLossTime === undefined) sd.lastLossTime = 0; if (sd.lastSwitchTime === undefined) sd.lastSwitchTime = 0; if (sd.lastExitBySymbol === undefined || typeof sd.lastExitBySymbol !== 'object' || Array.isArray(sd.lastExitBySymbol)) sd.lastExitBySymbol = {}; const today = new Date().toISOString().slice(0, 10); if (sd.todayDate !== today) { sd.todayDate = today; sd.todayTrades = 0; } // ── STATE SELF-HEAL ───────────────────────────────────────── // If sd.position is set but pendingFill stuck > 5 min, release it if (sd.pendingFill && (!sd.pendingFillTime || (Date.now() - sd.pendingFillTime) > 2 * 60000)) { sd.pendingFill = null; sd.pendingFillTime = null; } // Update capital from real USDT — already done in Build Order Request when balance is fetched. // Here just ensure capital is sane. if (sd.capital <= 0 || sd.capital > REAL_CAPITAL * 10) sd.capital = REAL_CAPITAL; // First-run: import existing position if configured manually if (!sd.position && INITIAL_POSITION) { sd.position = { ...INITIAL_POSITION, highWater: INITIAL_POSITION.entry, entryScore: 0 }; } const now = Date.now(); const utcH = new Date().getUTCHours(); const utcM = new Date().getUTCMinutes(); const cooldownOk = (now - sd.lastLossTime) > COOLDOWN_MIN * 60000; const tradeLimitOk = sd.todayTrades < MAX_TRADES_DAY; const capitalOk = sd.capital > MIN_CAPITAL; function normalizeSymbol(sym) { return (sym || '').toString().trim().toUpperCase(); } function getCoinCooldownRemainingMs(sym) { const key = normalizeSymbol(sym); if (!key) return 0; const lastExitTs = Number(sd.lastExitBySymbol[key] || 0); if (!lastExitTs) return 0; const elapsed = now - lastExitTs; return Math.max(0, COIN_COOLDOWN_MIN * 60000 - elapsed); } const coinCooldownRemainingMs = getCoinCooldownRemainingMs(bestSymbol); const coinCooldownOk = coinCooldownRemainingMs <= 0; function buildPortfolio() { return { capital: +sd.capital.toFixed(2), position: sd.position || null, trades: sd.trades, todayTrades: sd.todayTrades, wins: sd.wins, losses: sd.losses, totalPnl: +sd.pnl.toFixed(2), winRate: +(sd.trades > 0 ? sd.wins / sd.trades * 100 : 0).toFixed(1) }; } function closePos(pos, exitPrice, reason) { const gross = (exitPrice - pos.entry) * pos.qty; const entryFee = pos.entry * pos.qty * TAKER_FEE; const exitFee = exitPrice * pos.qty * TAKER_FEE; const netPnl = gross - entryFee - exitFee; const pct = (exitPrice - pos.entry) / pos.entry; const holdMins = +((now - new Date(pos.entryTime).getTime()) / 60000).toFixed(1); if (!LIVE_TRADING) { sd.capital += netPnl; sd.pnl += netPnl; sd.trades += 1; netPnl >= 0 ? sd.wins++ : sd.losses++; if (netPnl < 0) sd.lastLossTime = now; sd.position = null; } return { symbol: pos.symbol, entryPrice: +pos.entry.toFixed(8), exitPrice: +exitPrice.toFixed(8), qty: +pos.qty.toFixed(6), grossPnl: +gross.toFixed(4), fees: +(entryFee + exitFee).toFixed(4), netPnl: +netPnl.toFixed(4), pnlPct: +(pct * 100).toFixed(3), reason, holdMins, capital: +sd.capital.toFixed(2), totalPnl: +sd.pnl.toFixed(2), wins: sd.wins, losses: sd.losses, winRate: +(sd.trades > 0 ? sd.wins / sd.trades * 100 : 0).toFixed(1), entryTime: pos.entryTime, exitTime: new Date().toISOString() }; } function openPos(symbol, price, score, change24h, tp, sl) { const tradeValue = sd.capital * POSITION_PCT; const qty = tradeValue / price; if (!LIVE_TRADING) { sd.todayTrades += 1; sd.position = { symbol, entry: price, qty, tradeValue, tpPct: tp, slPct: sl, highWater: price, entryTime: new Date().toISOString(), entryScore: score }; } return { symbol, entryPrice: +price.toFixed(8), qty: +qty.toFixed(6), tradeValue: +tradeValue.toFixed(2), takeProfit: +(price * (1 + tp)).toFixed(8), stopLoss: +(price * (1 - sl)).toFixed(8), tpPct: +(tp * 100).toFixed(3), slPct: +(sl * 100).toFixed(3), score, change24h, indicators: klineIndicators, scoringDetail: best.scoringDetail || {}, capital: +sd.capital.toFixed(2), todayTrades: sd.todayTrades, timestamp: new Date().toISOString() }; } // ── NO CANDIDATES GUARD ────────────────────────────────────── if (ctx.noCandidates && !sd.position) { return [{ json: { action: 'hold', reason: 'no_candidates', btcSignal, entryChecks, portfolio: buildPortfolio(), bestCandidate: { symbol: ctx.bestSymbol || '', score: 0, change24h: 0, price: 0 }, scanSummary: ctx.scanSummary } }]; } // ── POSITION MANAGEMENT ────────────────────────────────────── const pos = sd.position || null; let action = 'hold'; let trade = null; if (pos) { // Use ticker price for the held coin — heldCurrentPrice is always updated from tickers in Pick Best Candidate // Fall back to pos.entry only if the coin disappeared from Binance entirely const currentPrice = heldCurrentPrice > 0 ? heldCurrentPrice : (pos.symbol === bestSymbol ? (best.price || pos.entry) : pos.entry); const peak = Math.max(pos.highWater || pos.entry, currentPrice); sd.position.highWater = peak; const pctFromEntry = (currentPrice - pos.entry) / pos.entry; const pctFromPeak = (peak - currentPrice) / peak; const heldMins = (now - new Date(pos.entryTime).getTime()) / 60000; let exitReason = null; if (pctFromEntry >= pos.tpPct) exitReason = 'Take Profit'; if (pctFromEntry <= -pos.slPct) exitReason = 'Stop Loss'; if (!exitReason && pctFromEntry >= TRAIL_TRIGGER && pctFromPeak >= TRAIL_OFFSET) exitReason = 'Trailing Stop'; if (!exitReason && utcH === 23 && utcM >= 45 && pctFromEntry > 0) exitReason = 'EOD Close'; if (!exitReason && heldMins >= MAX_HOLD_MINS) exitReason = 'Max Hold Time'; if (!exitReason && btcSignal.roc5 < BTC_BEAR_ROC5_EMERGENCY) exitReason = 'BTC Emergency Exit'; if (exitReason) { const closed = closePos(pos, currentPrice, exitReason); action = closed.netPnl >= 0 ? 'close_tp' : 'close_sl'; trade = closed; } else { // Switch — close current, open best next cycle const timeSinceSwitch = now - (sd.lastSwitchTime || 0); const canSwitch = ( pctFromEntry > -0.005 && // not losing more than 0.5% pos.symbol !== bestSymbol && (best.score || 0) > (pos.entryScore || 0) + SWITCH_DELTA && (now - new Date(pos.entryTime).getTime()) > SWITCH_MIN_HOLD && timeSinceSwitch > SWITCH_COOLDOWN && tradeLimitOk && entryConfirmed ); if (canSwitch) { const closed = closePos(pos, currentPrice, 'Switch to ' + bestSymbol); sd.lastSwitchTime = now; sd.pendingEntry = { symbol: bestSymbol, price: best.price, score: best.score || 0, change24h: best.change24h }; action = 'close_switch'; trade = closed; } } } else { // ── ENTRY ──────────────────────────────────────────────── // Gate: RSI not extreme, capital available, not already waiting for fill // BTC trend does NOT block entry — it only affects TP/SL tightness via ATR const roundTripFees = 2 * TAKER_FEE; // 0.2% const profitableEntry = (tpPct - roundTripFees) >= 0.001; // need >0.1% net after fees if (sd.pendingFill) { trade = { holdReasons: ['awaitingFill'] }; } else if (sd.pendingEntry) { // Priority re-entry after a switch const pe = sd.pendingEntry; const pendingCooldownMs = getCoinCooldownRemainingMs(pe.symbol); if (pendingCooldownMs > 0) { const mins = Math.ceil(pendingCooldownMs / 60000); trade = { holdReasons: ['coinCooldown(' + normalizeSymbol(pe.symbol) + ':' + mins + 'm)'] }; } else { action = 'buy'; trade = openPos(pe.symbol, pe.price, pe.score || 0, pe.change24h || 0, tpPct, slPct); sd.pendingFill = true; sd.pendingFillTime = Date.now(); } } else if (cooldownOk && coinCooldownOk && tradeLimitOk && capitalOk && !ctx.noCandidates && entryConfirmed && profitableEntry) { action = 'buy'; trade = openPos(bestSymbol, best.price, best.score || 0, best.change24h || 0, tpPct, slPct); sd.pendingFill = true; sd.pendingFillTime = Date.now(); } else { const holdReasons = []; if (!cooldownOk) holdReasons.push('cooldown'); if (!tradeLimitOk) holdReasons.push('dayLimit'); if (!coinCooldownOk) { const mins = Math.ceil(coinCooldownRemainingMs / 60000); holdReasons.push('coinCooldown(' + normalizeSymbol(bestSymbol) + ':' + mins + 'm)'); } if (!capitalOk) holdReasons.push('lowCapital($' + sd.capital.toFixed(0) + ')'); if (ctx.noCandidates) holdReasons.push('noCandidates'); if (!entryConfirmed) holdReasons.push('rsiExtreme(' + (entryChecks.rsi||'?') + ')'); if (!profitableEntry) holdReasons.push('notProfitable(tp:' + (tpPct*100).toFixed(2) + '%)'); trade = { holdReasons }; } } return [{ json: { action, trade, btcSignal, entryChecks, bestCandidate: { symbol: bestSymbol, score: best.score, change24h: best.change24h, price: best.price }, scanSummary: ctx.scanSummary, portfolio: buildPortfolio(), klineOk: rawKlines.length >= 20, timestamp: new Date().toISOString() }}];