{
  "name": "Crypto Artbitrage Bot",
  "nodes": [
    {
      "parameters": {
        "content": "## 🤖 Crypto Arbitrage Bot — Paper Trading Simulator\n\n**Simulates** cross-exchange arbitrage with virtual balances. No real money.\n\n**Telegram Control (recommended):**\nSend `/menu` or `/start` to your bot — shows an interactive button menu with live balances.\nTap **Set Pair**, **Set Markets**, or **Set Trade Size** — no typing needed!\n\n**Telegram Commands:**\n`/menu` / `/start` — interactive button menu (shows live balances + P/L)\n`/run`  — start bot with current setup\n`/status` `/stop` `/reset` `/test` `/help`\n\n**Webhook Control:**\n`POST /webhook/arb-bot-control`\n`{ \"action\": \"start\", \"market1\": \"binance\", \"market2\": \"kucoin\", \"pair\": \"BTC/USDT\", \"tradeSize\": 500 }`\n`{ \"action\": \"stop\" | \"status\" | \"reset\" }`\n\n**Markets:** binance, kucoin, bybit, okx, gateio, mexc, kraken, htx\n**Pairs:** BTC/USDT, ETH/USDT, SOL/USDT, XRP/USDT, BNB/USDT, ADA/USDT, DOGE/USDT, AVAX/USDT, LINK/USDT, DOT/USDT, TRX/USDT, MATIC/USDT, ARB/USDT, OP/USDT, STRK/USDT\n**Trade Sizes:** $50, $100, $200, $500, $1000, $2000, $5000\n\n**Default:** $10,000 per market | BTC/USDT | binance vs kucoin | $500 trade size\n**Auto-rebalance** when any market drops below 15% of starting balance\n\n⚠️ Add Telegram credentials before using Telegram alerts",
        "height": 508,
        "width": 1072,
        "color": 4
      },
      "id": "7f4ac4d6-f821-41cb-a47e-b8054a4fb3f0",
      "name": "Bot Overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        43584,
        6624
      ]
    },
    {
      "parameters": {
        "content": "## ⚙️ Config Reference\n\n**Available Markets (public APIs, no auth):**\n- `binance` — Binance (0.1% fee)\n- `kucoin` — KuCoin (0.1% fee)\n- `bybit` — Bybit (0.1% fee)\n- `okx` — OKX (0.1% fee)\n- `gateio` — Gate.io (0.2% fee)\n- `mexc` — MEXC (0.2% fee)\n- `kraken` — Kraken (0.2% fee)\n\n- `htx` ? HTX (0.2% fee)\n\n**Available Pairs:**\n- BTC/USDT (default)\n- ETH/USDT\n- SOL/USDT\n- XRP/USDT\n- BNB/USDT\n- ADA/USDT\n- DOGE/USDT\n- AVAX/USDT\n- LINK/USDT\n- DOT/USDT\n- TRX/USDT\n- MATIC/USDT\n- ARB/USDT\n- OP/USDT\n- STRK/USDT\n\n**Trade params (override in start body):**\n- `startingBalance`: 10000\n- `tradeSize`: 500\n- `threshold`: 0.20 (% net spread)\n- `rebalanceThreshold`: 0.15",
        "height": 416,
        "width": 1088,
        "color": 6
      },
      "id": "a32d3946-9fc2-4359-9e1e-903643abdcd4",
      "name": "Config Reference",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        43584,
        7168
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "arb-bot-control",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "a59c0ecb-a391-4339-ae4a-b970479a60c1",
      "name": "Control Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        44832,
        6928
      ],
      "webhookId": "arb-bot-control-001"
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{ $json.body.action }}",
        "rules": {
          "rules": [
            {
              "value2": "start"
            },
            {
              "value2": "stop",
              "output": 1
            },
            {
              "value2": "status",
              "output": 2
            },
            {
              "value2": "reset",
              "output": 3
            },
            {
              "value2": "test_alert",
              "output": 4
            }
          ]
        }
      },
      "id": "e6107e2a-572c-46b6-ac18-c214de455937",
      "name": "Route Command",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 1,
      "position": [
        45744,
        6896
      ]
    },
    {
      "parameters": {
        "jsCode": "const MARKETS = ['binance','kucoin','bybit','okx','gateio','mexc','kraken','htx'];\nconst PAIRS   = ['BTC/USDT','ETH/USDT','SOL/USDT','XRP/USDT','BNB/USDT','ADA/USDT','DOGE/USDT','AVAX/USDT','LINK/USDT','DOT/USDT','TRX/USDT','MATIC/USDT','ARB/USDT','OP/USDT','STRK/USDT'];\n\nconst state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst input = $input.first().json || {};\nconst source = input.source || 'webhook';\nconst body  = input.body || {};\n\nconst market1         = (body.market1 && MARKETS.includes(body.market1)) ? body.market1 : 'binance';\nconst market2         = (body.market2 && MARKETS.includes(body.market2)) ? body.market2 : 'kucoin';\nconst pair            = (body.pair    && PAIRS.includes(body.pair))       ? body.pair    : 'BTC/USDT';\nconst baseAsset       = pair.split('/')[0] || 'BTC';\nconst startingBalance = parseFloat(body.startingBalance) || 10000;\nconst reserveMult     = parseFloat(body.reserveMultiplier) || 3;\n\nif (market1 === market2) {\n  return [{ json: {\n    source, success: false, message: 'market1 and market2 must be different',\n    eventType: 'ERROR', errorCode: 'INVALID_MARKETS',\n    controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n    channelChatId: body.channelChatId || state.telegram.channelChatId || null\n  } }];\n}\n\n// Cap tradeSize to 35% of startingBalance so each market has room for USDT + base reserve\nconst rawTradeSize    = parseFloat(body.tradeSize) || 500;\nconst maxTradeSize    = parseFloat((startingBalance * 0.35).toFixed(2));\nconst tradeSize       = Math.min(rawTradeSize, maxTradeSize);\nconst tradeSizeCapped = tradeSize < rawTradeSize;\n\nconst PRICE_FALLBACK = {\n  'BTC/USDT': 83000, 'ETH/USDT': 1800,  'SOL/USDT': 130,\n  'XRP/USDT': 2.1,   'BNB/USDT': 580,   'ADA/USDT': 0.65,\n  'DOGE/USDT': 0.17, 'AVAX/USDT': 20,   'LINK/USDT': 13,\n  'DOT/USDT': 4,     'TRX/USDT': 0.24,  'MATIC/USDT': 0.25,\n  'ARB/USDT': 0.37,  'OP/USDT': 0.75,   'STRK/USDT': 0.034\n};\n\nconst [base, quote] = pair.split('/');\nconst MARKET_CFG = {\n  binance: { url: () => 'https://api.binance.com/api/v3/ticker/bookTicker?symbol=' + base + quote,                               parse: (d) => (parseFloat(d.bidPrice) + parseFloat(d.askPrice)) / 2 },\n  kucoin:  { url: () => 'https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=' + base + '-' + quote,                   parse: (d) => (parseFloat(d.data?.bestBid || 0) + parseFloat(d.data?.bestAsk || 0)) / 2 },\n  bybit:   { url: () => 'https://api.bybit.com/v5/market/tickers?category=spot&symbol=' + base + quote,                        parse: (d) => (parseFloat(d.result?.list?.[0]?.bid1Price || 0) + parseFloat(d.result?.list?.[0]?.ask1Price || 0)) / 2 },\n  okx:     { url: () => 'https://www.okx.com/api/v5/market/ticker?instId=' + base + '-' + quote,                               parse: (d) => (parseFloat(d.data?.[0]?.bidPx || 0) + parseFloat(d.data?.[0]?.askPx || 0)) / 2 },\n  gateio:  { url: () => 'https://api.gateio.ws/api/v4/spot/tickers?currency_pair=' + base + '_' + quote,                      parse: (d) => { const t = Array.isArray(d) ? d[0] : d; return (parseFloat(t?.highest_bid || 0) + parseFloat(t?.lowest_ask || 0)) / 2; } },\n  mexc:    { url: () => 'https://api.mexc.com/api/v3/ticker/bookTicker?symbol=' + base + quote,                                parse: (d) => (parseFloat(d.bidPrice || 0) + parseFloat(d.askPrice || 0)) / 2 },\n  kraken:  { url: () => { const kb = base === 'BTC' ? 'XBT' : base; return 'https://api.kraken.com/0/public/Ticker?pair=' + kb + quote; }, parse: (d) => { const key = Object.keys(d.result || {})[0]; const t = d.result?.[key]; return (parseFloat(t?.b?.[0] || 0) + parseFloat(t?.a?.[0] || 0)) / 2; } },\n  htx:     { url: () => 'https://api.huobi.pro/market/detail/merged?symbol=' + String(base + quote).toLowerCase(),            parse: (d) => (parseFloat(d.tick?.bid?.[0] || 0) + parseFloat(d.tick?.ask?.[0] || 0)) / 2 }\n};\n\nlet livePrice = PRICE_FALLBACK[pair] || 1;\nconst httpRequest = (this && this.helpers && this.helpers.httpRequest) ? this.helpers.httpRequest.bind(this) : null;\nif (httpRequest) {\n  for (const mkt of [market1, market2]) {\n    const mc = MARKET_CFG[mkt];\n    if (!mc) continue;\n    try {\n      const resp = await httpRequest({ method: 'GET', url: mc.url(), timeout: 6000, json: true });\n      const mid  = mc.parse(resp);\n      if (mid > 0) { livePrice = mid; break; }\n    } catch (_) {}\n  }\n}\n\n// initialBaseQty: enough base for reserveMult trades, capped at 50% of starting balance in value\nconst baseForTrades  = (tradeSize * reserveMult) / livePrice;\nconst baseCap        = (startingBalance * 0.50) / livePrice;\nconst initialBaseQty = Number.isFinite(parseFloat(body.initialBaseQty))\n  ? parseFloat(body.initialBaseQty)\n  : parseFloat(Math.min(baseForTrades, baseCap).toFixed(8));\n\nconst initLines = [\n  'Bot Online. Pair: ' + pair + ' | Markets: ' + market1 + ' vs ' + market2,\n  'Live price: $' + livePrice.toFixed(6) + ' | Trade size: $' + tradeSize,\n  'Base: ' + initialBaseQty.toFixed(2) + ' ' + baseAsset + ' (~$' + (initialBaseQty * livePrice).toFixed(2) + ')',\n  tradeSizeCapped ? 'Trade size capped from $' + rawTradeSize + ' to $' + tradeSize + ' (max 35% of $' + startingBalance + ')' : ''\n];\nconst initMsg = initLines.filter(Boolean).join('\\n');\n\nstate.running = true;\nstate.config  = { pair, market1, market2, startingBalance, tradeSize, threshold: parseFloat(body.threshold) || 0.20, rebalanceThreshold: parseFloat(body.rebalanceThreshold) || 0.15, initialBaseQty, livePrice, reserveMultiplier: reserveMult, baseAsset };\nstate.balances = { [market1]: { USDT: startingBalance, [baseAsset]: initialBaseQty }, [market2]: { USDT: startingBalance, [baseAsset]: initialBaseQty } };\nstate.stats    = { totalTrades: 0, totalPnL: 0, startTime: Date.now(), lastTradeTime: null };\nstate.tradeLog = [];\n\nif (body.controlChatId) state.telegram.lastControlChatId = String(body.controlChatId);\nif (body.channelChatId) state.telegram.channelChatId = String(body.channelChatId);\n\nreturn [{ json: { source, success: true, message: initMsg, eventType: 'BOT_STARTED', config: state.config, balances: state.balances, availableMarkets: MARKETS, availablePairs: PAIRS, controlChatId: body.controlChatId || state.telegram.lastControlChatId || null, channelChatId: body.channelChatId || state.telegram.channelChatId || null } }];\n"
      },
      "id": "059c12ea-0a50-4cc5-9d50-f57c410c2a98",
      "name": "Init Bot State",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        6736
      ]
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst source = input.source || 'webhook';\n\nstate.running = false;\nconst uptimeMs = (state.stats && state.stats.startTime) ? Date.now() - state.stats.startTime : 0;\nconst hours    = Math.floor(uptimeMs / 3600000);\nconst minutes  = Math.floor((uptimeMs % 3600000) / 60000);\n\nif (body.controlChatId) state.telegram.lastControlChatId = String(body.controlChatId);\nif (body.channelChatId) state.telegram.channelChatId = String(body.channelChatId);\n\nreturn [{ json: {\n  source,\n  success: true,\n  message: 'Bot stopped',\n  eventType: 'BOT_STOPPED',\n  uptime: hours + 'h ' + minutes + 'm',\n  stats: state.stats || {},\n  balances: state.balances || {},\n  pair: state.config?.pair || 'N/A',\n  markets: [state.config?.market1, state.config?.market2].filter(Boolean),\n  controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n  channelChatId: body.channelChatId || state.telegram.channelChatId || null\n} }];"
      },
      "id": "fefc0c0a-f211-4a52-a72b-423b166e6b3f",
      "name": "Stop Bot",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        6896
      ]
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst source = input.source || 'webhook';\n\nif (!state.config) {\n  const lastCycle = state.lastCycleResult || null;\n\nreturn [{ json: {\n    source,\n    running: false,\n    success: false,\n    message: 'Bot not initialized. Send {\"action\":\"start\"} first.',\n    eventType: 'ERROR',\n    errorCode: 'NOT_INITIALIZED',\n    controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n    channelChatId: body.channelChatId || state.telegram.channelChatId || null\n  } }];\n}\n\nconst uptimeMs = (state.stats && state.stats.startTime) ? Date.now() - state.stats.startTime : 0;\nconst hours    = Math.floor(uptimeMs / 3600000);\nconst minutes  = Math.floor((uptimeMs % 3600000) / 60000);\n\nconst b  = state.balances || {};\nconst m1 = state.config.market1;\nconst m2 = state.config.market2;\nconst baseAsset = state.config.baseAsset || String(state.config.pair || 'BTC/USDT').split('/')[0] || 'BTC';\nconst b1 = b[m1] || { USDT: 0, [baseAsset]: 0 };\nconst b2 = b[m2] || { USDT: 0, [baseAsset]: 0 };\n\nif (body.controlChatId) state.telegram.lastControlChatId = String(body.controlChatId);\nif (body.channelChatId) state.telegram.channelChatId = String(body.channelChatId);\n\nreturn [{ json: {\n  source,\n  success: true,\n  message: 'Bot status snapshot',\n  eventType: 'BOT_STATUS',\n  running: state.running || false,\n  pair: state.config.pair,\n  markets: [m1, m2],\n  uptime: hours + 'h ' + minutes + 'm',\n  balances: state.balances || {},\n  totalValue: {\n    note: baseAsset + ' valued at last known mid price (rough estimate)',\n    market1: '$' + Number(b1.USDT || 0).toFixed(2) + ' USDT + ' + Number(b1[baseAsset] || 0).toFixed(8) + ' ' + baseAsset,\n    market2: '$' + Number(b2.USDT || 0).toFixed(2) + ' USDT + ' + Number(b2[baseAsset] || 0).toFixed(8) + ' ' + baseAsset\n  },\n  stats: state.stats || {},\n  lastCycle: state.lastCycleResult || null,\n  recentTrades: (state.tradeLog || []).slice(-5),\n  controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n  channelChatId: body.channelChatId || state.telegram.channelChatId || null\n} }];"
      },
      "id": "43b0e8d7-0191-437e-abea-62db95eee1a9",
      "name": "Get Status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        7056
      ]
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst source = input.source || 'webhook';\n\nconst cfg       = state.config || {};\nconst m1        = cfg.market1         || 'binance';\nconst m2        = cfg.market2         || 'kucoin';\nconst start     = cfg.startingBalance || 10000;\nconst baseQ     = Number.isFinite(parseFloat(cfg.initialBaseQty)) ? parseFloat(cfg.initialBaseQty) : 0;\nconst baseAsset = cfg.baseAsset || String(cfg.pair || 'BTC/USDT').split('/')[0] || 'BTC';\n\nstate.balances = {\n  [m1]: { USDT: start, [baseAsset]: baseQ },\n  [m2]: { USDT: start, [baseAsset]: baseQ }\n};\nstate.stats    = { totalTrades: 0, totalPnL: 0, startTime: Date.now(), lastTradeTime: null };\nstate.tradeLog = [];\n\nif (body.controlChatId) state.telegram.lastControlChatId = String(body.controlChatId);\nif (body.channelChatId) state.telegram.channelChatId = String(body.channelChatId);\n\nreturn [{ json: {\n  source,\n  success: true,\n  message: 'Bot reset. Balances restored to $' + start.toLocaleString() + ' + ' + baseQ + ' ' + baseAsset + ' per market.',\n  eventType: 'BOT_RESET',\n  balances: state.balances,\n  config: cfg,\n  controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n  channelChatId: body.channelChatId || state.telegram.channelChatId || null\n} }];"
      },
      "id": "0b34a13e-885a-4a36-a7f5-2e3b08b7015d",
      "name": "Reset Bot",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        7216
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify($input.first().json, null, 2) }}",
        "options": {}
      },
      "id": "8093c8ae-d515-47d4-bf0a-cb317995cbfb",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        46608,
        7168
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "seconds",
              "secondsInterval": 15
            }
          ]
        }
      },
      "id": "de8a403f-aec9-4c5b-b5f0-d413fcc1ff32",
      "name": "Every 15 Seconds",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [
        45504,
        7504
      ]
    },
    {
      "parameters": {
        "jsCode": "// Gate: only proceed if bot is running — silently halts schedule when stopped\nconst state = $getWorkflowStaticData('global');\nif (!state.running || !state.config) return [];\nreturn [{ json: { running: true, config: state.config, balances: state.balances } }];"
      },
      "id": "dc1f3527-9920-4613-8a2e-545134f84fdb",
      "name": "Check If Running",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45744,
        7504
      ]
    },
    {
      "parameters": {
        "jsCode": "// Fetch real-time bid/ask from BOTH configured markets via public APIs (no auth required)\nconst state = $getWorkflowStaticData('global');\nconst config = state.config || { market1: 'binance', market2: 'kucoin', pair: 'BTC/USDT' };\nconst { market1, market2, pair } = config;\nconst [base, quote] = pair.split('/');\n\nconst httpRequest = (this && this.helpers && this.helpers.httpRequest)\n  ? this.helpers.httpRequest.bind(this)\n  : null;\n\nasync function requestJson(url) {\n  if (httpRequest) {\n    return await httpRequest({\n      method: 'GET',\n      url,\n      timeout: 5000,\n      json: true,\n    });\n  }\n\n  const res = await fetch(url, {\n    method: 'GET',\n    headers: { accept: 'application/json' },\n  });\n  if (!res.ok) throw new Error('HTTP ' + res.status + ' from ' + url);\n  return await res.json();\n}\n\nconst MARKET_CFG = {\n  binance: {\n    url: (b, q) => 'https://api.binance.com/api/v3/ticker/bookTicker?symbol=' + b + q,\n    parse: (d) => ({ bid: parseFloat(d.bidPrice), ask: parseFloat(d.askPrice) }),\n  },\n  kucoin: {\n    url: (b, q) => 'https://api.kucoin.com/api/v1/market/orderbook/level1?symbol=' + b + '-' + q,\n    parse: (d) => ({ bid: parseFloat(d.data?.bestBid || 0), ask: parseFloat(d.data?.bestAsk || 0) }),\n  },\n  bybit: {\n    url: (b, q) => 'https://api.bybit.com/v5/market/tickers?category=spot&symbol=' + b + q,\n    parse: (d) => ({ bid: parseFloat(d.result?.list?.[0]?.bid1Price || 0), ask: parseFloat(d.result?.list?.[0]?.ask1Price || 0) }),\n  },\n  okx: {\n    url: (b, q) => 'https://www.okx.com/api/v5/market/ticker?instId=' + b + '-' + q,\n    parse: (d) => ({ bid: parseFloat(d.data?.[0]?.bidPx || 0), ask: parseFloat(d.data?.[0]?.askPx || 0) }),\n  },\n  gateio: {\n    url: (b, q) => 'https://api.gateio.ws/api/v4/spot/tickers?currency_pair=' + b + '_' + q,\n    parse: (d) => {\n      const t = Array.isArray(d) ? d[0] : d;\n      return { bid: parseFloat(t?.highest_bid || 0), ask: parseFloat(t?.lowest_ask || 0) };\n    },\n  },\n  mexc: {\n    url: (b, q) => 'https://api.mexc.com/api/v3/ticker/bookTicker?symbol=' + b + q,\n    parse: (d) => ({ bid: parseFloat(d.bidPrice || 0), ask: parseFloat(d.askPrice || 0) }),\n  },\n  kraken: {\n    url: (b, q) => {\n      const kb = b === 'BTC' ? 'XBT' : b;\n      return 'https://api.kraken.com/0/public/Ticker?pair=' + kb + q;\n    },\n    parse: (d) => {\n      const key = Object.keys(d.result || {})[0];\n      const t = d.result?.[key];\n      return { bid: parseFloat(t?.b?.[0] || 0), ask: parseFloat(t?.a?.[0] || 0) };\n    },\n  },\n  htx: {\n    url: (b, q) => 'https://api.huobi.pro/market/detail/merged?symbol=' + String(b + q).toLowerCase(),\n    parse: (d) => ({ bid: parseFloat(d.tick?.bid?.[0] || 0), ask: parseFloat(d.tick?.ask?.[0] || 0) }),\n  },\n};\n\nasync function fetchPrice(market) {\n  const mc = MARKET_CFG[market];\n  if (!mc) return { market, bid: 0, ask: 0, mid: 0, error: 'Unknown market: ' + market };\n\n  const url = mc.url(base, quote);\n  try {\n    const data = await requestJson(url);\n    const prices = mc.parse(data);\n\n    if (!prices.bid || !prices.ask || prices.bid <= 0 || prices.ask <= 0) {\n      throw new Error('Invalid prices: bid=' + prices.bid + ' ask=' + prices.ask);\n    }\n\n    return {\n      market,\n      bid: prices.bid,\n      ask: prices.ask,\n      mid: (prices.bid + prices.ask) / 2,\n      timestamp: Date.now(),\n    };\n  } catch (err) {\n    return {\n      market,\n      bid: 0,\n      ask: 0,\n      mid: 0,\n      error: err.message,\n      timestamp: Date.now(),\n    };\n  }\n}\n\nconst [price1, price2] = await Promise.all([fetchPrice(market1), fetchPrice(market2)]);\n\nif (price1.error || price2.error) {\n  const errMsg = [\n    price1.error && market1 + ': ' + price1.error,\n    price2.error && market2 + ': ' + price2.error,\n  ]\n    .filter(Boolean)\n    .join(' | ');\n\n  state.lastCycleResult = {\n    timestamp: new Date().toISOString(),\n    fetchError: errMsg,\n    price1: { market: price1.market, bid: price1.bid, ask: price1.ask, error: price1.error },\n    price2: { market: price2.market, bid: price2.bid, ask: price2.ask, error: price2.error },\n    isProfitable: false,\n    reason: 'PRICE FETCH ERROR: ' + errMsg\n  };\n  return [{ json: { fetchError: errMsg, price1, price2, pair, market1, market2, isProfitable: false } }];\n}\n\nreturn [{ json: { pair, base, quote, market1, market2, price1, price2, timestamp: Date.now() } }];"
      },
      "id": "70b41c57-8d99-4751-b607-75ba8fce51d4",
      "name": "Fetch Both Prices",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        7504
      ]
    },
    {
      "parameters": {
        "jsCode": "// Calculate best arbitrage direction and net spread after fees + slippage\nconst item = $input.first().json;\nif (item.fetchError || item.isProfitable === false) {\n  return [{ json: { ...item, isProfitable: false } }];\n}\n\nconst FEES = { binance:0.001, kucoin:0.001, bybit:0.001, okx:0.001, gateio:0.002, mexc:0.002, kraken:0.002, htx:0.002 };\nconst SLIP = 0.0005; // 0.05% slippage per side\n\nconst state     = $getWorkflowStaticData('global');\nconst config    = state.config   || {};\nconst threshold = config.threshold  || 0.20;\nconst tradeSize = config.tradeSize  || 500;\n\nconst { market1, market2, price1, price2, pair, base } = item;\nconst baseAsset = config.baseAsset || base || String(pair || 'BTC/USDT').split('/')[0] || 'BTC';\nconst fee1 = FEES[market1] || 0.002;\nconst fee2 = FEES[market2] || 0.002;\n\nconst effBuyA  = price1.ask * (1 + fee1 + SLIP);\nconst effSellA = price2.bid * (1 - fee2 - SLIP);\nconst spreadA  = ((effSellA - effBuyA) / effBuyA) * 100;\nconst qtyA     = tradeSize / effBuyA;\nconst profitA  = (qtyA * effSellA) - tradeSize;\n\nconst effBuyB  = price2.ask * (1 + fee2 + SLIP);\nconst effSellB = price1.bid * (1 - fee1 - SLIP);\nconst spreadB  = ((effSellB - effBuyB) / effBuyB) * 100;\nconst qtyB     = tradeSize / effBuyB;\nconst profitB  = (qtyB * effSellB) - tradeSize;\n\nconst useA        = spreadA >= spreadB;\nconst buyMarket   = useA ? market1 : market2;\nconst sellMarket  = useA ? market2 : market1;\nconst buyPrice    = useA ? price1.ask   : price2.ask;\nconst sellPrice   = useA ? price2.bid   : price1.bid;\nconst effectiveBuy  = useA ? effBuyA  : effBuyB;\nconst effectiveSell = useA ? effSellA : effSellB;\nconst netSpread   = useA ? spreadA  : spreadB;\nconst netProfit   = useA ? profitA  : profitB;\nconst qty         = useA ? qtyA     : qtyB;\n\nconst balances = state.balances || {};\nconst buyBal   = balances[buyMarket]  || { USDT: 0, [baseAsset]: 0 };\nconst sellBal  = balances[sellMarket] || { USDT: 0, [baseAsset]: 0 };\nconst canBuy   = Number(buyBal.USDT || 0) >= tradeSize;\nconst canSell  = Number(sellBal[baseAsset] || 0) >= qty;\nconst isProfitable = netSpread >= threshold && canBuy && canSell;\n\nconst notTradedReason = isProfitable ? null\n  : !canBuy  ? 'Insufficient USDT on ' + buyMarket + ' (have $' + Number(buyBal.USDT || 0).toFixed(0) + ', need $' + tradeSize + ')'\n  : !canSell ? 'Insufficient ' + baseAsset + ' on ' + sellMarket + ' (have ' + Number(sellBal[baseAsset] || 0).toFixed(2) + ', need ' + qty.toFixed(2) + ')'\n  : 'Net spread ' + netSpread.toFixed(4) + '% < threshold ' + threshold + '%';\n\n// Persist last cycle result — surfaced by /status\nconst cycleM1Bid = price1.bid;\nconst cycleM1Ask = price1.ask;\nconst cycleM2Bid = price2.bid;\nconst cycleM2Ask = price2.ask;\nconst cycleBalBuy  = { USDT: parseFloat(Number(buyBal.USDT  || 0).toFixed(2)), base: parseFloat(Number(buyBal[baseAsset]  || 0).toFixed(4)) };\nconst cycleBalSell = { USDT: parseFloat(Number(sellBal.USDT || 0).toFixed(2)), base: parseFloat(Number(sellBal[baseAsset] || 0).toFixed(4)) };\nstate.lastCycleResult = {\n  timestamp:  new Date().toISOString(),\n  pair,\n  market1:    market1,\n  market2:    market2,\n  m1Bid: cycleM1Bid, m1Ask: cycleM1Ask,\n  m2Bid: cycleM2Bid, m2Ask: cycleM2Ask,\n  bestRoute:  buyMarket.toUpperCase() + ' buy -> ' + sellMarket.toUpperCase() + ' sell',\n  netSpread:  parseFloat(netSpread.toFixed(4)),\n  buyPrice:   parseFloat(buyPrice.toFixed(8)),\n  sellPrice:  parseFloat(sellPrice.toFixed(8)),\n  qty:        parseFloat(qty.toFixed(4)),\n  estProfit:  parseFloat(netProfit.toFixed(4)),\n  canBuy:     canBuy,\n  canSell:    canSell,\n  isProfitable: isProfitable,\n  reason:     isProfitable ? 'ELIGIBLE' : notTradedReason,\n  buyMarketBal:  cycleBalBuy,\n  sellMarketBal: cycleBalSell,\n  buyMarketName:  buyMarket,\n  sellMarketName: sellMarket,\n  baseAsset:  baseAsset\n};\n\nreturn [{ json: {\n  ...item,\n  baseAsset, isProfitable, notTradedReason,\n  buyMarket, sellMarket,\n  buyPrice:      parseFloat(buyPrice.toFixed(8)),\n  sellPrice:     parseFloat(sellPrice.toFixed(8)),\n  effectiveBuy:  parseFloat(effectiveBuy.toFixed(8)),\n  effectiveSell: parseFloat(effectiveSell.toFixed(8)),\n  netSpread:     parseFloat(netSpread.toFixed(4)),\n  netProfit:     parseFloat(netProfit.toFixed(4)),\n  qty:           parseFloat(qty.toFixed(8)),\n  tradeSize, threshold\n} }];"
      },
      "id": "1e840c0c-ccef-4ddb-a73d-a15809a20bc4",
      "name": "Calculate Spread",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        46224,
        7504
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "is-prof",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.isProfitable }}",
              "rightValue": true
            }
          ]
        },
        "options": {}
      },
      "id": "ff621745-edde-4a61-a130-e433c3d86735",
      "name": "IF Profitable",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        46464,
        7504
      ]
    },
    {
      "parameters": {
        "jsCode": "const item  = $input.first().json;\nconst state = $getWorkflowStaticData('global');\nconst {\n  buyMarket, sellMarket,\n  buyPrice, sellPrice,\n  effectiveBuy, effectiveSell,\n  qty, tradeSize, netProfit, netSpread,\n  pair, price1, price2\n} = item;\n\nconst baseAsset = item.baseAsset || state.config?.baseAsset || String(pair || 'BTC/USDT').split('/')[0] || 'BTC';\nconst balances = state.balances || {};\nif (!balances[buyMarket]) balances[buyMarket] = { USDT: 0, [baseAsset]: 0 };\nif (!balances[sellMarket]) balances[sellMarket] = { USDT: 0, [baseAsset]: 0 };\nif (!Number.isFinite(Number(balances[buyMarket][baseAsset]))) balances[buyMarket][baseAsset] = 0;\nif (!Number.isFinite(Number(balances[sellMarket][baseAsset]))) balances[sellMarket][baseAsset] = 0;\n\nbalances[buyMarket].USDT              -= tradeSize;\nbalances[buyMarket][baseAsset]        += qty;\nbalances[sellMarket][baseAsset]       -= qty;\nbalances[sellMarket].USDT             += qty * effectiveSell;\n\nbalances[buyMarket].USDT        = parseFloat(Number(balances[buyMarket].USDT || 0).toFixed(4));\nbalances[buyMarket][baseAsset]  = parseFloat(Number(balances[buyMarket][baseAsset] || 0).toFixed(8));\nbalances[sellMarket][baseAsset] = parseFloat(Number(balances[sellMarket][baseAsset] || 0).toFixed(8));\nbalances[sellMarket].USDT       = parseFloat(Number(balances[sellMarket].USDT || 0).toFixed(4));\n\nstate.stats.totalTrades   = (state.stats.totalTrades || 0) + 1;\nstate.stats.totalPnL      = parseFloat(((state.stats.totalPnL || 0) + netProfit).toFixed(4));\nstate.stats.lastTradeTime = Date.now();\n\nconst pnlPct = tradeSize > 0 ? (netProfit / tradeSize) * 100 : 0;\nconst tradeEntry = {\n  id:        state.stats.totalTrades,\n  timestamp: new Date().toISOString(),\n  type:      'POSITION_OPENED',\n  pair,      buyMarket, sellMarket,\n  buyPrice,  sellPrice,\n  qty:       parseFloat(qty.toFixed(8)),\n  tradeSize,\n  baseAsset,\n  netProfit: parseFloat(netProfit.toFixed(4)),\n  netSpread: parseFloat(netSpread.toFixed(4)),\n  pnlPct:    parseFloat(pnlPct.toFixed(4))\n};\nstate.tradeLog = [...(state.tradeLog || []).slice(-49), tradeEntry];\n\nreturn [{ json: {\n  eventType: 'POSITION_OPENED',\n  trade: tradeEntry,\n  balances: JSON.parse(JSON.stringify(balances)),\n  stats: { ...state.stats },\n  price1, price2,\n  controlChatId: state.telegram?.lastControlChatId || null,\n  channelChatId: state.telegram?.channelChatId || null\n} }];"
      },
      "id": "dc745402-9d7e-4351-9298-1ffbbcf0b6c6",
      "name": "Execute Simulated Trade",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        46704,
        7408
      ]
    },
    {
      "parameters": {
        "jsCode": "// Check if any market needs rebalancing after the trade\nconst item   = $input.first().json;\nconst state  = $getWorkflowStaticData('global');\nconst config = state.config || {};\nconst { market1, market2, pair } = config;\nconst baseAsset = config.baseAsset || String(pair || 'BTC/USDT').split('/')[0] || 'BTC';\nconst startingBalance = config.startingBalance    || 10000;\nconst rebalThreshold  = config.rebalanceThreshold || 0.15;\nconst minValue        = startingBalance * rebalThreshold;\n\nconst mid1      = item.price1?.mid || 0;\nconst mid2      = item.price2?.mid || 0;\nconst basePrice = ((mid1 + mid2) / 2) || 100;\n\nconst b   = state.balances || {};\nconst b1  = b[market1] || { USDT: 0, [baseAsset]: 0 };\nconst b2  = b[market2] || { USDT: 0, [baseAsset]: 0 };\n\nconst b1USDTVal = Number(b1.USDT || 0);\nconst b1BaseVal = Number(b1[baseAsset] || 0) * basePrice;\nconst b2USDTVal = Number(b2.USDT || 0);\nconst b2BaseVal = Number(b2[baseAsset] || 0) * basePrice;\n\nconst reasons = [];\nif (b1USDTVal < minValue) reasons.push(market1 + ' USDT low ($' + b1USDTVal.toFixed(0) + ' < $' + minValue.toFixed(0) + ')');\nif (b2USDTVal < minValue) reasons.push(market2 + ' USDT low ($' + b2USDTVal.toFixed(0) + ' < $' + minValue.toFixed(0) + ')');\nif (b1BaseVal < minValue && Number(b1[baseAsset] || 0) > 0.00001) reasons.push(market1 + ' ' + baseAsset + ' value low ($' + b1BaseVal.toFixed(0) + ')');\nif (b2BaseVal < minValue && Number(b2[baseAsset] || 0) > 0.00001) reasons.push(market2 + ' ' + baseAsset + ' value low ($' + b2BaseVal.toFixed(0) + ')');\n\nreturn [{ json: {\n  ...item,\n  needsRebalance:    reasons.length > 0,\n  rebalanceReasons:  reasons,\n  baseAsset,\n  basePrice:         parseFloat(basePrice.toFixed(6)),\n  balanceSnapshot: {\n    [market1]: { USDT: b1USDTVal.toFixed(2), baseValue: b1BaseVal.toFixed(2), baseAsset, baseQty: Number(b1[baseAsset] || 0) },\n    [market2]: { USDT: b2USDTVal.toFixed(2), baseValue: b2BaseVal.toFixed(2), baseAsset, baseQty: Number(b2[baseAsset] || 0) }\n  }\n} }];"
      },
      "id": "55214328-edcf-4822-ae0d-074a3a94c921",
      "name": "Check Rebalance",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        46944,
        7408
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "needs-reb",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.needsRebalance }}",
              "rightValue": true
            }
          ]
        },
        "options": {}
      },
      "id": "5b4bd23b-7003-4cf7-9996-aab41bcb2b1c",
      "name": "IF Rebalance Needed",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        47184,
        7408
      ]
    },
    {
      "parameters": {
        "jsCode": "const item   = $input.first().json;\nconst state  = $getWorkflowStaticData('global');\nconst config = state.config || {};\nconst { market1, market2, pair } = config;\nconst baseAsset = config.baseAsset || item.baseAsset || String(pair || 'BTC/USDT').split('/')[0] || 'BTC';\nconst basePrice = item.basePrice || 100;\nconst balances = state.balances || {};\n\nif (!balances[market1]) balances[market1] = { USDT: 0, [baseAsset]: 0 };\nif (!balances[market2]) balances[market2] = { USDT: 0, [baseAsset]: 0 };\nconst b1 = balances[market1];\nconst b2 = balances[market2];\n\n// Snapshot before\nconst before = {\n  [market1]: { USDT: parseFloat(Number(b1.USDT || 0).toFixed(2)), baseQty: Number(b1[baseAsset] || 0), baseValueUSD: parseFloat((Number(b1[baseAsset] || 0) * basePrice).toFixed(2)) },\n  [market2]: { USDT: parseFloat(Number(b2.USDT || 0).toFixed(2)), baseQty: Number(b2[baseAsset] || 0), baseValueUSD: parseFloat((Number(b2[baseAsset] || 0) * basePrice).toFixed(2)) }\n};\n\n// Cross-exchange withdrawal fee (network fee + exchange withdrawal fee)\n// Typical real-world: ~0.1% of transferred value\nconst TRANSFER_FEE = 0.001;\n\nlet transferredBaseQty  = 0;\nlet transferredUSDT     = 0;\nlet feeBase             = 0;\nlet feeUSDT             = 0;\nlet baseDirection       = '';\nlet usdtDirection       = '';\n\n// ── Step 1: Equalize BASE across markets (transfer coin from excess to deficit side)\nconst b1Base = Number(b1[baseAsset] || 0);\nconst b2Base = Number(b2[baseAsset] || 0);\nconst totalBase = b1Base + b2Base;\nconst targetBase = totalBase / 2;\nconst baseDiff = b1Base - targetBase; // positive = m1 has excess, negative = m2 has excess\n\nif (Math.abs(baseDiff) > 0.000001) {\n  feeBase = Math.abs(baseDiff) * TRANSFER_FEE;\n  transferredBaseQty = Math.abs(baseDiff);\n  if (baseDiff > 0) {\n    // Transfer base from market1 → market2\n    baseDirection = market1 + ' → ' + market2;\n    balances[market1][baseAsset] = parseFloat((b1Base - baseDiff).toFixed(8));\n    balances[market2][baseAsset] = parseFloat((b2Base + baseDiff - feeBase).toFixed(8));\n  } else {\n    // Transfer base from market2 → market1\n    baseDirection = market2 + ' → ' + market1;\n    balances[market2][baseAsset] = parseFloat((b2Base + baseDiff).toFixed(8)); // baseDiff is negative\n    balances[market1][baseAsset] = parseFloat((b1Base - baseDiff - feeBase).toFixed(8));\n  }\n}\n\n// ── Step 2: Equalize USDT across markets (wire transfer from cash-rich to cash-poor side)\nconst b1USDT = Number(balances[market1].USDT || 0);\nconst b2USDT = Number(balances[market2].USDT || 0);\nconst totalUSDT = b1USDT + b2USDT;\nconst targetUSDT = totalUSDT / 2;\nconst usdtDiff = b1USDT - targetUSDT; // positive = m1 has excess USDT\n\nif (Math.abs(usdtDiff) > 0.01) {\n  feeUSDT = Math.abs(usdtDiff) * TRANSFER_FEE;\n  transferredUSDT = Math.abs(usdtDiff);\n  if (usdtDiff > 0) {\n    usdtDirection = market1 + ' → ' + market2;\n    balances[market1].USDT = parseFloat((b1USDT - usdtDiff).toFixed(4));\n    balances[market2].USDT = parseFloat((b2USDT + usdtDiff - feeUSDT).toFixed(4));\n  } else {\n    usdtDirection = market2 + ' → ' + market1;\n    balances[market2].USDT = parseFloat((b2USDT + usdtDiff).toFixed(4));\n    balances[market1].USDT = parseFloat((b1USDT - usdtDiff - feeUSDT).toFixed(4));\n  }\n}\n\nconst totalFeeUSD = parseFloat((feeUSDT + feeBase * basePrice).toFixed(4));\n\n// Snapshot after\nconst after = {\n  [market1]: { USDT: parseFloat(Number(balances[market1].USDT || 0).toFixed(2)), baseQty: Number(balances[market1][baseAsset] || 0), baseValueUSD: parseFloat((Number(balances[market1][baseAsset] || 0) * basePrice).toFixed(2)) },\n  [market2]: { USDT: parseFloat(Number(balances[market2].USDT || 0).toFixed(2)), baseQty: Number(balances[market2][baseAsset] || 0), baseValueUSD: parseFloat((Number(balances[market2][baseAsset] || 0) * basePrice).toFixed(2)) }\n};\n\nconst grandTotal = Number(balances[market1].USDT || 0) + Number(balances[market2].USDT || 0)\n                 + (Number(balances[market1][baseAsset] || 0) + Number(balances[market2][baseAsset] || 0)) * basePrice;\n\n// Deduct transfer fees from P&L tracking\nstate.stats.totalPnL = parseFloat(((state.stats.totalPnL || 0) - totalFeeUSD).toFixed(4));\n\nconst rebalEntry = {\n  timestamp:       new Date().toISOString(),\n  type:            'REBALANCE',\n  reasons:         item.rebalanceReasons,\n  before, after,\n  transferredBaseQty: parseFloat(transferredBaseQty.toFixed(8)),\n  transferredUSDT:    parseFloat(transferredUSDT.toFixed(4)),\n  baseDirection, usdtDirection,\n  totalFeeUSD,\n  totalPortfolio:  parseFloat(grandTotal.toFixed(2)),\n  pnlSoFar:        state.stats?.totalPnL || 0\n};\nstate.tradeLog = [...(state.tradeLog || []).slice(-49), rebalEntry];\n\nreturn [{ json: {\n  eventType:  'REBALANCE',\n  trade:       item.trade,\n  rebalance:   rebalEntry,\n  balances:    JSON.parse(JSON.stringify(balances)),\n  stats:       state.stats || {},\n  controlChatId: state.telegram?.lastControlChatId || null,\n  channelChatId: state.telegram?.channelChatId || null\n} }];"
      },
      "id": "8dcfdd8f-87e3-46a4-beaf-ca93834e8f1c",
      "name": "Execute Rebalance",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        47584,
        7104
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\nif (!item || !item.eventType) return [];\n\nconst fmt  = (v, d = 2) => Number(v || 0).toFixed(d);\nconst sign = (v) => (Number(v || 0) >= 0 ? '+' : '');\nlet message = '';\n\nswitch (item.eventType) {\n\n  case 'BOT_STARTED': {\n    const c = item.config || {};\n    message = [\n      'Bot Online',\n      'Pair: ' + (c.pair || 'N/A'),\n      'Markets: ' + String(c.market1 || 'N/A').toUpperCase() + ' vs ' + String(c.market2 || 'N/A').toUpperCase(),\n      'Trade Size: $' + fmt(c.tradeSize),\n      'Threshold: ' + fmt(c.threshold, 4) + '%',\n      item.message || ''\n    ].filter(Boolean).join('\\n');\n    break;\n  }\n\n  case 'BOT_STOPPED': {\n    message = [\n      'BOT STOPPED',\n      'Uptime: ' + (item.uptime || '0h 0m'),\n      'Trades: ' + (item.stats?.totalTrades || 0),\n      'Total P/L: ' + sign(item.stats?.totalPnL) + '$' + fmt(item.stats?.totalPnL, 4)\n    ].join('\\n');\n    break;\n  }\n\n  case 'BOT_STATUS': {\n    const lc = item.lastCycle || null;\n    const lines = [\n      'BOT STATUS',\n      'Running: ' + (item.running ? 'YES' : 'NO'),\n      'Pair: ' + (item.pair || 'N/A'),\n      'Markets: ' + ((item.markets || []).map(function(m) { return m.toUpperCase(); }).join(' vs ')),\n      'Trades: ' + (item.stats?.totalTrades || 0),\n      'Total P/L: ' + sign(item.stats?.totalPnL) + '$' + fmt(item.stats?.totalPnL, 4),\n      'Uptime: ' + (item.uptime || '0h 0m')\n    ];\n    if (lc) {\n      lines.push('');\n      lines.push('Last cycle: ' + (lc.timestamp || 'N/A'));\n      lines.push('Route: ' + (lc.bestRoute || 'N/A'));\n      lines.push('Buy $' + fmt(lc.buyPrice, 8) + '  Sell $' + fmt(lc.sellPrice, 8));\n      lines.push('Spread: ' + fmt(lc.netSpread, 4) + '%  Est P/L: $' + fmt(lc.estProfit, 4));\n      lines.push('canBuy: ' + (lc.canBuy ? 'YES' : 'NO') + '   canSell: ' + (lc.canSell ? 'YES' : 'NO'));\n      lines.push('Result: ' + (lc.reason || 'N/A'));\n      if (lc.buyMarketName && lc.sellMarketName) {\n        lines.push('');\n        lines.push('Balances:');\n        var base = lc.baseAsset || 'BASE';\n        lines.push('  ' + lc.buyMarketName.toUpperCase()  + ': $' + fmt(lc.buyMarketBal.USDT)  + ' + ' + fmt(lc.buyMarketBal.base,  4) + ' ' + base);\n        lines.push('  ' + lc.sellMarketName.toUpperCase() + ': $' + fmt(lc.sellMarketBal.USDT) + ' + ' + fmt(lc.sellMarketBal.base, 4) + ' ' + base);\n      }\n    } else {\n      lines.push('No cycle data yet.');\n    }\n    message = lines.join('\\n');\n    break;\n  }\n\n  case 'BOT_RESET': {\n    message = ['BOT RESET', item.message || 'Balances restored.'].join('\\n');\n    break;\n  }\n\n  case 'POSITION_OPENED': {\n    const t = item.trade || {};\n    const b = item.balances || {};\n    const base = t.baseAsset || 'BASE';\n    const bBuy  = b[t.buyMarket]  || {};\n    const bSell = b[t.sellMarket] || {};\n    message = [\n      'TRADE EXECUTED',\n      'Pair: ' + (t.pair || 'N/A'),\n      'Buy  ' + String(t.buyMarket  || '').toUpperCase() + ' @ $' + fmt(t.buyPrice, 8),\n      'Sell ' + String(t.sellMarket || '').toUpperCase() + ' @ $' + fmt(t.sellPrice, 8),\n      'Qty: ' + fmt(t.qty, 6) + ' ' + base + '  Size: $' + fmt(t.tradeSize),\n      'Spread: ' + fmt(t.netSpread, 4) + '%',\n      'P/L: ' + sign(t.netProfit) + '$' + fmt(t.netProfit, 4) + ' (' + sign(t.pnlPct) + fmt(t.pnlPct, 4) + '%)',\n      '',\n      'Balances after:',\n      String(t.buyMarket  || '').toUpperCase() + ': $' + fmt(bBuy.USDT)  + ' + ' + fmt(bBuy[base]  || 0, 4) + ' ' + base,\n      String(t.sellMarket || '').toUpperCase() + ': $' + fmt(bSell.USDT) + ' + ' + fmt(bSell[base] || 0, 4) + ' ' + base,\n      '',\n      'Session #' + (item.stats?.totalTrades || 0) + '  Total P/L: ' + sign(item.stats?.totalPnL) + '$' + fmt(item.stats?.totalPnL, 4)\n    ].join('\\n');\n    break;\n  }\n\n  case 'REBALANCE': {\n    const r = item.rebalance || {};\n    const lines = [\n      'REBALANCE EXECUTED',\n      'Reasons: ' + ((r.reasons || []).join('; ') || 'N/A'),\n      '',\n      'Base: ' + (r.baseDirection ? fmt(r.transferredBaseQty, 6) + '  ' + r.baseDirection : 'none'),\n      'USDT: ' + (r.usdtDirection ? '$' + fmt(r.transferredUSDT) + '  ' + r.usdtDirection : 'none'),\n      'Fees: -$' + fmt(r.totalFeeUSD, 4),\n      '',\n      'Portfolio: $' + fmt(r.totalPortfolio),\n      'Total P/L: ' + sign(r.pnlSoFar) + '$' + fmt(r.pnlSoFar, 4)\n    ];\n    if (r.after) {\n      lines.push('');\n      lines.push('New balances:');\n      Object.keys(r.after).forEach(function(m) {\n        var a = r.after[m];\n        lines.push(m.toUpperCase() + ': $' + fmt(a.USDT) + ' + ' + fmt(a.baseQty, 4) + ' ($' + fmt(a.baseValueUSD) + ')');\n      });\n    }\n    message = lines.join('\\n');\n    break;\n  }\n\n  case 'ERROR': {\n    message = ['ERROR', item.errorCode ? 'Code: ' + item.errorCode : '', item.message || 'Unknown error'].filter(Boolean).join('\\n');\n    break;\n  }\n\n  default:\n    return [];\n}\n\nreturn [{ json: { ...item, message } }];"
      },
      "id": "9aef5c18-4c9e-41e1-8bf5-93dc78ac3299",
      "name": "Build Telegram Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        47872,
        7312
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $json.targetChatId || 'YOUR_CHAT_ID_HERE' }}",
        "text": "={{ $json.message }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "7592ecf8-6a03-47cc-a2f4-1ab2c1828f9f",
      "name": "Send Telegram Alert",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        48256,
        7392
      ],
      "webhookId": "93bf97e5-d885-47aa-8d6b-0984244c5db5",
      "credentials": {
        "telegramApi": {
          "id": "h4R1YAQOdP4Lxsm1",
          "name": "BOT Crypto Arbitrage"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst input = $input.first().json || {};\nconst body = input.body || {};\nconst source = input.source || 'webhook';\n\nconst pair = body.pair || 'BTC/USDT';\nconst buyMarket = body.market1 || 'binance';\nconst sellMarket = body.market2 || 'kucoin';\nconst buyPrice = Number(body.buyPrice || 65000);\nconst sellPrice = Number(body.sellPrice || 65150);\nconst qty = Number(body.qty || 0.01);\nconst tradeSize = Number(body.tradeSize || 650);\nconst netProfit = Number(body.netProfit || ((sellPrice - buyPrice) * qty));\nconst netSpread = Number(body.netSpread || ((sellPrice - buyPrice) / buyPrice) * 100);\nconst pnlPct = tradeSize > 0 ? (netProfit / tradeSize) * 100 : 0;\n\nif (body.controlChatId) state.telegram.lastControlChatId = String(body.controlChatId);\nif (body.channelChatId) state.telegram.channelChatId = String(body.channelChatId);\n\nreturn [{ json: {\n  source,\n  success: true,\n  message: 'Synthetic TEST_ALERT emitted',\n  eventType: 'TEST_ALERT',\n  trade: {\n    id: 'test-' + Date.now(),\n    timestamp: new Date().toISOString(),\n    pair,\n    buyMarket,\n    sellMarket,\n    buyPrice,\n    sellPrice,\n    qty,\n    tradeSize,\n    netProfit,\n    netSpread,\n    pnlPct\n  },\n  controlChatId: body.controlChatId || state.telegram.lastControlChatId || null,\n  channelChatId: body.channelChatId || state.telegram.channelChatId || null\n} }];"
      },
      "id": "e5fcf8d0-0ffe-4992-8458-0c362a957d78",
      "name": "Create Test Alert Event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45984,
        7376
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "is-webhook",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.source || 'webhook' }}",
              "rightValue": "webhook"
            }
          ]
        },
        "options": {}
      },
      "id": "4f74257b-d4c4-448c-8d9b-8f5af8eaeed7",
      "name": "IF Webhook Source",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        46224,
        7184
      ]
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nconst item = $input.first().json || {};\n\nconst channelChatId = String(item.channelChatId || state.telegram.channelChatId || 'YOUR_CHAT_ID_HERE');\nconst controlChatId = item.controlChatId\n  ? String(item.controlChatId)\n  : (state.telegram.lastControlChatId ? String(state.telegram.lastControlChatId) : '');\n\nconst eventType = String(item.eventType || '');\nconst source = String(item.source || '');\n\nconst controlOnlyEvents = new Set(['BOT_STATUS', 'BOT_RESET', 'BOT_STOPPED', 'TEST_ALERT', 'ERROR']);\nconst monitorEvents = new Set(['POSITION_OPENED', 'REBALANCE']);\n\nconst targets = [];\n\nif (controlOnlyEvents.has(eventType)) {\n  if (controlChatId) targets.push(controlChatId);\n} else if (monitorEvents.has(eventType)) {\n  if (channelChatId) targets.push(channelChatId);\n  if (controlChatId && controlChatId !== channelChatId) targets.push(controlChatId);\n} else if (eventType === 'BOT_STARTED') {\n  // If started from Telegram command, respond in control chat only.\n  // If started from webhook/api, notify channel and control chat.\n  if (source === 'telegram') {\n    if (controlChatId) targets.push(controlChatId);\n  } else {\n    if (channelChatId) targets.push(channelChatId);\n    if (controlChatId && controlChatId !== channelChatId) targets.push(controlChatId);\n  }\n} else {\n  // Fallback behavior\n  if (channelChatId) targets.push(channelChatId);\n  if (controlChatId && controlChatId !== channelChatId) targets.push(controlChatId);\n}\n\nconst uniqueTargets = [...new Set(targets)].filter(Boolean);\nreturn uniqueTargets.map((targetChatId) => ({ json: { ...item, targetChatId } }));"
      },
      "id": "ef63a286-3b79-4716-940a-17cdfb6bf2dc",
      "name": "Build Alert Targets",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        48064,
        7408
      ]
    },
    {
      "parameters": {
        "updates": [
          "message",
          "callback_query",
          "channel_post"
        ],
        "additionalFields": {}
      },
      "id": "a6517ed8-a043-4065-b2ce-a6edbd8d6d16",
      "name": "Telegram Control Trigger",
      "type": "n8n-nodes-base.telegramTrigger",
      "typeVersion": 1.1,
      "position": [
        45296,
        7888
      ],
      "webhookId": "arb-bot-telegram-control",
      "credentials": {
        "telegramApi": {
          "id": "h4R1YAQOdP4Lxsm1",
          "name": "BOT Crypto Arbitrage"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const u = $input.first().json || {};\n\n// Support multiple Telegram Trigger payload shapes\nconst cb = u.callback_query || null;\nconst msg = u.message || null;\nconst channelPost = u.channel_post || null;\n\nconst rootHasCallbackShape = !!(u.data && u.from && (u.message || u.chat));\nconst rootHasMessageShape = !!(u.chat && u.from);\n\nconst cbObj = cb || (rootHasCallbackShape ? u : null);\nconst msgObj = msg || (rootHasMessageShape ? u : null);\nconst postObj = channelPost || null;\n\nconst isCallback = !!cbObj;\n\nconst chatId = String(\n  isCallback\n    ? (cbObj.message?.chat?.id || cbObj.chat?.id || '')\n    : (msgObj?.chat?.id || postObj?.chat?.id || u.chat?.id || '')\n);\n\nconst userId = String(\n  isCallback\n    ? (cbObj.from?.id || chatId)\n    : (msgObj?.from?.id || postObj?.from?.id || chatId)\n);\n\nconst text = isCallback\n  ? ''\n  : String(msgObj?.text || postObj?.text || u.text || '').trim();\n\nconst callbackData = isCallback\n  ? String(cbObj.data || u.data || '')\n  : '';\n\nconst callbackId = isCallback\n  ? String(cbObj.id || u.id || '')\n  : '';\n\nconst state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\nif (!state.telegram.sessions) state.telegram.sessions = {};\nconst channelChatId = String(state.telegram.channelChatId || '-1003804576516');\n\nif (!chatId) return [{ json: { actionType: 'noop' } }];\n\nreturn [{ json: {\n  chatId,\n  userId,\n  text,\n  callbackData,\n  callbackId,\n  isCallback,\n  channelChatId\n} }];"
      },
      "id": "61a833c0-9919-47c8-84fd-813c92f497b4",
      "name": "Parse Telegram Update",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45536,
        7712
      ]
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json || {};\n\ntry {\n  const state = $getWorkflowStaticData('global');\n  if (!state.telegram) state.telegram = {};\n  if (!state.telegram.sessions) state.telegram.sessions = {};\n\n  const MARKETS = ['binance','kucoin','bybit','okx','gateio','mexc','kraken','htx'];\n  const PAIRS   = ['BTC/USDT','ETH/USDT','SOL/USDT','XRP/USDT','BNB/USDT','ADA/USDT','DOGE/USDT','AVAX/USDT','LINK/USDT','DOT/USDT','TRX/USDT','MATIC/USDT','ARB/USDT','OP/USDT','STRK/USDT'];\n\n  if (input.actionType === 'noop') return [{ json: { actionType: 'noop' } }];\n\n  const chatId        = String(input.chatId || '');\n  const userId        = String(input.userId || chatId);\n  const text          = String(input.text || '').trim();\n  const callbackData  = String(input.callbackData || '');\n  const callbackId    = String(input.callbackId || '');\n  const channelChatId = String(input.channelChatId || '-1003804576516');\n\n  const parts          = text.split(/\\s+/).filter(Boolean);\n  const normalizedText = (parts[0] || '').split('@')[0].toLowerCase();\n\n  if (!state.telegram.sessions[userId]) {\n    state.telegram.sessions[userId] = { market1: 'binance', market2: 'kucoin', pair: 'BTC/USDT', tradeSize: 500 };\n  }\n  const session = state.telegram.sessions[userId];\n  if (state.config?.tradeSize) session.tradeSize = Number(state.config.tradeSize);\n  if (!session.tradeSize) session.tradeSize = 500;\n\n  const chunkArray = (arr, size) => {\n    const chunks = [];\n    for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size));\n    return chunks;\n  };\n\n  const menuHeader = () => {\n    const cfg  = state.config || {};\n    const m1   = session.market1;\n    const m2   = session.market2;\n    const bal  = state.balances || {};\n    const base = cfg.baseAsset || session.pair.split('/')[0] || 'BTC';\n\n    const fmtBal = (market) => {\n      const b = bal[market];\n      if (!b) return market.toUpperCase() + ': no data';\n      return market.toUpperCase() + ': $' + Number(b.USDT || 0).toFixed(2) + ' USDT + ' + Number(b[base] || 0).toFixed(6) + ' ' + base;\n    };\n\n    const running = state.running ? 'RUNNING' : 'STOPPED';\n    const lines = [\n      'Crypto Arbitrage Bot  [' + running + ']',\n      'Pair: ' + session.pair + '   Markets: ' + m1.toUpperCase() + ' vs ' + m2.toUpperCase(),\n      'Trade Size: $' + session.tradeSize\n    ];\n    if (bal[m1] || bal[m2]) {\n      lines.push('');\n      lines.push('Balances:');\n      if (bal[m1]) lines.push('  ' + fmtBal(m1));\n      if (bal[m2]) lines.push('  ' + fmtBal(m2));\n      if (state.stats?.totalTrades > 0) {\n        const sign = (state.stats.totalPnL || 0) >= 0 ? '+' : '';\n        lines.push('  P/L: ' + sign + '$' + Number(state.stats.totalPnL || 0).toFixed(4) + '  Trades: ' + state.stats.totalTrades);\n      }\n    }\n    return lines.join('\\n');\n  };\n\n  // ── Free-text trade size input ───────────────────────────────────────────\n  // If user is awaiting input AND this is a plain text message (not a command, not a callback)\n  if (session.awaitingInput === 'tradeSize' && text && !callbackData && !normalizedText.startsWith('/')) {\n    const num = parseFloat(text.replace(/[^0-9.]/g, ''));\n    session.awaitingInput = null;\n    if (!num || num < 1 || num > 1000000) {\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Invalid amount. Enter a number between 1 and 1,000,000. Try again via /menu.' } }];\n    }\n    session.tradeSize = num;\n    if (!state.config) state.config = {};\n    state.config.tradeSize = num;\n    return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n      message: 'Trade size set to $' + num + '\\nPair: ' + session.pair + '  Markets: ' + session.market1.toUpperCase() + ' vs ' + session.market2.toUpperCase() } }];\n  }\n\n  // ── Set trade size button → prompt free text ─────────────────────────────\n  if (callbackData === 'menu:set_trade_size') {\n    session.awaitingInput = 'tradeSize';\n    return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n      message: 'Current trade size: $' + session.tradeSize + '\\nType the new trade size (e.g. 250):' } }];\n  }\n\n  // ── Pair selection callback ──────────────────────────────────────────────\n  if (callbackData.startsWith('pair:')) {\n    const pair = callbackData.slice(5);\n    if (PAIRS.includes(pair)) {\n      session.pair = pair;\n      session.awaitingInput = null;\n      if (!state.config) state.config = {};\n      state.config.pair = pair;\n      state.config.baseAsset = pair.split('/')[0] || 'BTC';\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Pair set to ' + pair + '\\nTrade Size: $' + session.tradeSize + '  Markets: ' + session.market1.toUpperCase() + ' vs ' + session.market2.toUpperCase() } }];\n    }\n  }\n\n  // ── Market 2 selection callback ──────────────────────────────────────────\n  if (callbackData.startsWith('market2:')) {\n    const sub = callbackData.slice(8).split(':');\n    const m1 = sub[0]; const m2 = sub[1];\n    if (MARKETS.includes(m1) && MARKETS.includes(m2) && m1 !== m2) {\n      session.market1 = m1; session.market2 = m2;\n      session.awaitingInput = null;\n      if (!state.config) state.config = {};\n      state.config.market1 = m1; state.config.market2 = m2;\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Markets set: ' + m1.toUpperCase() + ' vs ' + m2.toUpperCase() + '\\nPair: ' + session.pair + '  Size: $' + session.tradeSize } }];\n    }\n  }\n\n  // ── Market 1 selection callback → show market 2 picker ──────────────────\n  if (callbackData.startsWith('market1:')) {\n    const m1 = callbackData.slice(8);\n    if (MARKETS.includes(m1)) {\n      const others = MARKETS.filter(m => m !== m1);\n      const rows = chunkArray(others.map(m => ({ text: m.toUpperCase(), callback_data: 'market2:' + m1 + ':' + m })), 4);\n      return [{ json: { actionType: 'send_keyboard', chatId, callbackId, channelChatId,\n        message: 'Market 1: ' + m1.toUpperCase() + '\\nNow choose Market 2:', keyboard: rows } }];\n    }\n  }\n\n  // ── Set pair menu callback ───────────────────────────────────────────────\n  if (callbackData === 'menu:set_pair') {\n    session.awaitingInput = null;\n    const rows = chunkArray(PAIRS.map(p => ({ text: p + (p === session.pair ? ' ✓' : ''), callback_data: 'pair:' + p })), 3);\n    return [{ json: { actionType: 'send_keyboard', chatId, callbackId, channelChatId,\n      message: 'Select trading pair (current: ' + session.pair + '):', keyboard: rows } }];\n  }\n\n  // ── Set markets menu callback ────────────────────────────────────────────\n  if (callbackData === 'menu:set_markets') {\n    session.awaitingInput = null;\n    const rows = chunkArray(MARKETS.map(m => ({ text: m.toUpperCase(), callback_data: 'market1:' + m })), 4);\n    return [{ json: { actionType: 'send_keyboard', chatId, callbackId, channelChatId,\n      message: 'Select Market 1 (current: ' + session.market1.toUpperCase() + ' vs ' + session.market2.toUpperCase() + '):', keyboard: rows } }];\n  }\n\n  // ── /start or /menu → main inline keyboard menu ─────────────────────────\n  const isMenuCommand = normalizedText === '/menu' || normalizedText === 'menu' || normalizedText === '/start' || normalizedText === 'start';\n  if (isMenuCommand || callbackData === 'menu:main') {\n    session.awaitingInput = null;\n    const menuKeyboard = [\n      [{ text: 'Start Bot', callback_data: 'menu:start' },   { text: 'Stop Bot', callback_data: 'menu:stop' }],\n      [{ text: 'Status', callback_data: 'menu:status' },     { text: 'Reset', callback_data: 'menu:reset' }],\n      [{ text: 'Set Pair', callback_data: 'menu:set_pair' }, { text: 'Set Markets', callback_data: 'menu:set_markets' }],\n      [{ text: 'Set Trade Size ($' + session.tradeSize + ')', callback_data: 'menu:set_trade_size' }],\n      [{ text: 'Help', callback_data: 'menu:help' }]\n    ];\n    return [{ json: { actionType: 'send_keyboard', chatId, callbackId, channelChatId,\n      message: menuHeader(), keyboard: menuKeyboard } }];\n  }\n\n  const helpMessage = [\n    'Crypto Arbitrage Bot Help',\n    '/menu or /start  — interactive button menu',\n    '/run             — start bot with current setup',\n    '/status          — status + balances snapshot',\n    '/stop            — stop bot',\n    '/reset           — reset balances/stats',\n    '',\n    'Tip: /menu shows live balances.',\n    'Tap Set Trade Size and type any amount!'\n  ].join('\\n');\n\n  // ── /run → start bot ─────────────────────────────────────────────────────\n  const isRunCommand = normalizedText === '/run' || normalizedText === 'run';\n  if (isRunCommand || callbackData === 'menu:start') {\n    session.awaitingInput = null;\n    if (session.market1 === session.market2) {\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Cannot start: market1 and market2 must be different.' } }];\n    }\n    const tradeSize          = session.tradeSize || Number(state.config?.tradeSize || 500);\n    const threshold          = Number(state.config?.threshold || 0.2);\n    const rebalanceThreshold = Number(state.config?.rebalanceThreshold || 0.15);\n    const startingBalance    = Number(state.config?.startingBalance || 10000);\n    const reserveMultiplier  = Number(state.config?.reserveMultiplier || 3);\n    return [{ json: { actionType: 'command', chatId, callbackId, channelChatId,\n      body: { action: 'start', market1: session.market1, market2: session.market2, pair: session.pair,\n              tradeSize, threshold, rebalanceThreshold, startingBalance, reserveMultiplier,\n              controlChatId: chatId, channelChatId } } }];\n  }\n\n  if (normalizedText === '/help' || normalizedText === 'help' || callbackData === 'menu:help') {\n    session.awaitingInput = null;\n    return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId, message: helpMessage } }];\n  }\n\n  if (normalizedText === '/status' || normalizedText === 'status' || callbackData === 'menu:status') {\n    session.awaitingInput = null;\n    return [{ json: { actionType: 'command', chatId, callbackId, channelChatId,\n      body: { action: 'status', controlChatId: chatId, channelChatId } } }];\n  }\n\n  if (normalizedText === '/stop' || normalizedText === 'stop' || callbackData === 'menu:stop') {\n    session.awaitingInput = null;\n    return [{ json: { actionType: 'command', chatId, callbackId, channelChatId,\n      body: { action: 'stop', controlChatId: chatId, channelChatId } } }];\n  }\n\n  if (normalizedText === '/reset' || normalizedText === 'reset' || callbackData === 'menu:reset') {\n    session.awaitingInput = null;\n    return [{ json: { actionType: 'command', chatId, callbackId, channelChatId,\n      body: { action: 'reset', controlChatId: chatId, channelChatId } } }];\n  }\n\n  // ── Legacy text fallbacks ─────────────────────────────────────────────────\n  if (normalizedText === '/setpair' || normalizedText === 'setpair') {\n    session.awaitingInput = null;\n    const pair = String(parts[1] || '').toUpperCase();\n    if (!PAIRS.includes(pair)) {\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Invalid pair. Use /menu > Set Pair.' } }];\n    }\n    session.pair = pair;\n    if (!state.config) state.config = {};\n    state.config.pair = pair;\n    state.config.baseAsset = pair.split('/')[0] || 'BTC';\n    return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId, message: 'Pair set to ' + pair } }];\n  }\n\n  if (normalizedText === '/setmarkets' || normalizedText === 'setmarkets') {\n    session.awaitingInput = null;\n    const m1 = String(parts[1] || '').toLowerCase();\n    const m2 = String(parts[2] || '').toLowerCase();\n    if (!MARKETS.includes(m1) || !MARKETS.includes(m2)) {\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'Invalid markets. Use /menu > Set Markets.' } }];\n    }\n    if (m1 === m2) {\n      return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n        message: 'market1 and market2 must be different.' } }];\n    }\n    session.market1 = m1; session.market2 = m2;\n    if (!state.config) state.config = {};\n    state.config.market1 = m1; state.config.market2 = m2;\n    return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n      message: 'Markets set: ' + m1.toUpperCase() + ' vs ' + m2.toUpperCase() } }];\n  }\n\n  return [{ json: { actionType: 'send_message', chatId, callbackId, channelChatId,\n    message: 'Command not recognized. Use /menu for options.' } }];\n} catch (err) {\n  return [{ json: { actionType: 'send_message',\n    chatId: String(input.chatId || ''),\n    callbackId: String(input.callbackId || ''),\n    channelChatId: String(input.channelChatId || '-1003804576516'),\n    message: 'Router error: ' + (err.message || 'unknown') + '\\nUse /menu to retry.' } }];\n}"
      },
      "id": "6fd87c8c-561d-4a5f-afaf-46bc4994762c",
      "name": "Telegram Action Router",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        46192,
        7728
      ]
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{ $json.actionType }}",
        "rules": {
          "rules": [
            {
              "value2": "send_message"
            },
            {
              "value2": "command",
              "output": 1
            },
            {
              "value2": "deny",
              "output": 2
            },
            {
              "value2": "send_keyboard",
              "output": 3
            }
          ]
        }
      },
      "id": "de1a3c2a-8e03-4c70-bd0e-4a67d6baca91",
      "name": "Switch Telegram Action",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 1,
      "position": [
        46416,
        7728
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $json.chatId }}",
        "text": "={{ $json.message }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "bc315464-7592-415e-81e7-8fc279dba4eb",
      "name": "HTTP: Telegram Send Control Message",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        46752,
        7616
      ],
      "webhookId": "c3247746-ddc7-45d3-b102-95ba933ccb7f",
      "credentials": {
        "telegramApi": {
          "id": "h4R1YAQOdP4Lxsm1",
          "name": "BOT Crypto Arbitrage"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const input = $input.first().json || {};\nif (input.actionType !== 'command') return [];\nreturn [{ json: {\n  source: 'telegram',\n  body: input.body || {},\n  controlChatId: input.chatId,\n  channelChatId: input.channelChatId || null,\n  callbackId: input.callbackId || ''\n} }];"
      },
      "id": "3e7ffc7e-6357-43b1-98ec-84cc9ae97d2b",
      "name": "Build Telegram Control Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        46752,
        7808
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "has-cb",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.callbackId || '' }}",
              "rightValue": ""
            }
          ]
        },
        "options": {}
      },
      "id": "25798667-7944-4114-ad36-a752f828b207",
      "name": "IF Has Callback ID",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        46496,
        8096
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.telegram.org/bot8512122941:AAFhpyLeO3rFhaI7Vcd7eFD010QUhWoD1F0/answerCallbackQuery",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({ callback_query_id: $json.callbackId }) }}",
        "options": {}
      },
      "id": "59f828da-9257-4f93-a241-16a7ecb13c10",
      "name": "HTTP: Answer Callback Query",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        46816,
        8080
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "seconds",
              "secondsInterval": 5
            }
          ]
        }
      },
      "id": "a7b2faee-ccf5-4695-8988-92efd5ee0368",
      "name": "Every 5 Seconds (Telegram Fallback)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [
        44816,
        7712
      ]
    },
    {
      "parameters": {
        "url": "={{ 'https://api.telegram.org/bot8512122941:AAFhpyLeO3rFhaI7Vcd7eFD010QUhWoD1F0/getUpdates?timeout=0&offset=' + ($json.offset || 0) + '&allowed_updates=%5B%22message%22,%22callback_query%22,%22channel_post%22%5D' }}",
        "options": {}
      },
      "id": "3f347ace-db47-4af8-a0d6-c3797d502212",
      "name": "HTTP: Telegram getUpdates (Fallback)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        45136,
        7712
      ],
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\n\nconst input = $input.first().json || {};\nconst updates = Array.isArray(input.result) ? input.result : [];\nif (!updates.length) return [];\n\nconst lastSeen = Number(state.telegram.lastPolledUpdateId || 0);\nconst fresh = updates\n  .filter((u) => Number(u.update_id || 0) > lastSeen)\n  .sort((a, b) => Number(a.update_id || 0) - Number(b.update_id || 0));\n\nif (!fresh.length) return [];\nstate.telegram.lastPolledUpdateId = Number(fresh[fresh.length - 1].update_id || lastSeen);\n\nreturn fresh.map((u) => ({ json: u }));"
      },
      "id": "c55918e9-1f9c-4446-ae47-e9f2714fb5e9",
      "name": "Extract New Telegram Updates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        45312,
        7712
      ]
    },
    {
      "parameters": {
        "jsCode": "const state = $getWorkflowStaticData('global');\nif (!state.telegram) state.telegram = {};\n\nconst botToken = '8512122941:AAFhpyLeO3rFhaI7Vcd7eFD010QUhWoD1F0';\nconst baseUrl = 'https://api.telegram.org/bot' + botToken;\n\nasync function requestJson(url) {\n  const httpRequest = (this && this.helpers && this.helpers.httpRequest)\n    ? this.helpers.httpRequest.bind(this)\n    : null;\n\n  if (httpRequest) {\n    return await httpRequest({\n      method: 'GET',\n      url,\n      timeout: 5000,\n      json: true,\n    });\n  }\n\n  const res = await fetch(url, {\n    method: 'GET',\n    headers: { accept: 'application/json' },\n  });\n\n  if (!res.ok) {\n    throw new Error('HTTP ' + res.status + ' from ' + url);\n  }\n\n  return await res.json();\n}\n\ntry {\n  const webhookInfo = await requestJson.call(this, baseUrl + '/getWebhookInfo');\n  const activeWebhookUrl = String(webhookInfo?.result?.url || '').trim();\n\n  if (activeWebhookUrl) {\n    state.telegram.pollingDisabledReason = 'Webhook active: ' + activeWebhookUrl;\n    return [];\n  }\n\n  state.telegram.pollingDisabledReason = null;\n} catch (error) {\n  state.telegram.pollingDisabledReason = 'Webhook check failed: ' + error.message;\n  return [];\n}\n\nconst offset = Number(state.telegram.lastPolledUpdateId || 0) + 1;\nreturn [{ json: { offset } }];"
      },
      "id": "44e0e978-b63b-4d4b-8165-20a27a4334f9",
      "name": "Build Telegram Poll Params",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        44976,
        7712
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.telegram.org/bot8512122941:AAFhpyLeO3rFhaI7Vcd7eFD010QUhWoD1F0/sendMessage",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({ chat_id: $json.chatId, text: $json.message, reply_markup: { inline_keyboard: $json.keyboard } }) }}",
        "options": {}
      },
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "name": "HTTP: Telegram Send Keyboard",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        46752,
        7952
      ],
      "continueOnFail": true
    }
  ],
  "pinData": {},
  "connections": {
    "Control Webhook": {
      "main": [
        [
          {
            "node": "Route Command",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Command": {
      "main": [
        [
          {
            "node": "Init Bot State",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop Bot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get Status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reset Bot",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Create Test Alert Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Init Bot State": {
      "main": [
        [
          {
            "node": "IF Webhook Source",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Stop Bot": {
      "main": [
        [
          {
            "node": "IF Webhook Source",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Status": {
      "main": [
        [
          {
            "node": "IF Webhook Source",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reset Bot": {
      "main": [
        [
          {
            "node": "IF Webhook Source",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 15 Seconds": {
      "main": [
        [
          {
            "node": "Check If Running",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check If Running": {
      "main": [
        [
          {
            "node": "Fetch Both Prices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Both Prices": {
      "main": [
        [
          {
            "node": "Calculate Spread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Spread": {
      "main": [
        [
          {
            "node": "IF Profitable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Profitable": {
      "main": [
        [
          {
            "node": "Execute Simulated Trade",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Simulated Trade": {
      "main": [
        [
          {
            "node": "Check Rebalance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Rebalance": {
      "main": [
        [
          {
            "node": "IF Rebalance Needed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Rebalance Needed": {
      "main": [
        [
          {
            "node": "Execute Rebalance",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Rebalance": {
      "main": [
        [
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Telegram Message": {
      "main": [
        [
          {
            "node": "Build Alert Targets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Test Alert Event": {
      "main": [
        [
          {
            "node": "IF Webhook Source",
            "type": "main",
            "index": 0
          },
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Webhook Source": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Alert Targets": {
      "main": [
        [
          {
            "node": "Send Telegram Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telegram Control Trigger": {
      "main": [
        [
          {
            "node": "Parse Telegram Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Telegram Update": {
      "main": [
        [
          {
            "node": "Telegram Action Router",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Telegram Action Router": {
      "main": [
        [
          {
            "node": "Switch Telegram Action",
            "type": "main",
            "index": 0
          },
          {
            "node": "IF Has Callback ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch Telegram Action": {
      "main": [
        [
          {
            "node": "HTTP: Telegram Send Control Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Telegram Control Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HTTP: Telegram Send Control Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "HTTP: Telegram Send Keyboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Telegram Control Payload": {
      "main": [
        [
          {
            "node": "Route Command",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Has Callback ID": {
      "main": [
        [
          {
            "node": "HTTP: Answer Callback Query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every 5 Seconds (Telegram Fallback)": {
      "main": [
        [
          {
            "node": "Build Telegram Poll Params",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP: Telegram getUpdates (Fallback)": {
      "main": [
        [
          {
            "node": "Extract New Telegram Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract New Telegram Updates": {
      "main": [
        [
          {
            "node": "Parse Telegram Update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Telegram Poll Params": {
      "main": [
        [
          {
            "node": "HTTP: Telegram getUpdates (Fallback)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "versionId": "9f985944-fafc-4bb1-927b-840b6cd1bee1",
  "meta": {
    "templateCredsSetupCompleted": true,
    "instanceId": "e09d1f0f8b9f78e80ac70b6dc8726dd263b0b6ffc4300b0eee3b58f6da316f29"
  },
  "id": "17sAgbhdpvoHBMAQ",
  "tags": [
    {
      "updatedAt": "2026-04-02T19:11:17.798Z",
      "createdAt": "2026-04-02T19:11:17.798Z",
      "id": "IjO0hWOLtF5iUQIq",
      "name": "Bot"
    },
    {
      "updatedAt": "2026-03-17T19:44:52.030Z",
      "createdAt": "2026-03-17T19:44:52.030Z",
      "id": "lD7oCDT7Xh6mgzKv",
      "name": "Trading"
    }
  ]
}
