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