{
  "id": "l40Qu7DpgYM2JHVg",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "AI Stock Analysis for Buy/No-Buy Decisions Using Google Sheets & EODHD APIs",
  "tags": [],
  "nodes": [
    {
      "id": "903c0cd1-5734-4a4d-99de-03acaf28595c",
      "name": "When clicking \u2018Execute workflow\u2019",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -1024,
        100
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "53f2218c-55ce-4b2e-964b-a119fdabc486",
      "name": "Get row(s) in sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        -800,
        100
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "dad4cbe0-e5b3-417e-8bba-5adaaf1cb570",
      "name": "Loop Over Items",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        -576,
        100
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "edf3eaa2-5b9d-40f9-a0fe-b526fbc2c06b",
      "name": "Merge",
      "type": "n8n-nodes-base.merge",
      "position": [
        320,
        -24
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "92132dbf-383f-47ca-a4e9-5a26d8631978",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        840,
        200
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "gpt-4.1-nano"
        },
        "options": {},
        "builtInTools": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "ec661db6-824a-44e7-8e97-1e62bb472f1b",
      "name": "Code in JavaScript2",
      "type": "n8n-nodes-base.code",
      "position": [
        1120,
        -16
      ],
      "parameters": {
        "jsCode": "// Parse AI output string -> JSON object\nvar raw = items[0].json.output;\n\nif (!raw || typeof raw !== 'string') {\n  return [{ json: { error: 'No output string to parse', raw: raw } }];\n}\n\nvar obj = JSON.parse(raw);\n\n// Devuelve el objeto ya parseado\nreturn [{ json: obj }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "cda8d21f-0612-41d7-a673-17d5abe93df7",
      "name": "Append row in sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1568,
        100
      ],
      "parameters": {
        "columns": {
          "value": {
            "ENTRY": "={{ $json.entry }}",
            "ticker": "={{ $json.ticker }}",
            "support": "={{ $json.support }}",
            "negative": "={{ $json.negatives }}",
            "positives": "={{ $json.positives }}",
            "stop_loss": "={{ $json.stop_loss }}",
            "resistance": "={{ $json.resistance }}",
            "take_profit": "={{ $json.take_profit }}",
            "ENTER(YES/NO)": "={{ $json.would_enter }}",
            "tecnhical_tesis": "={{ $json.rationale }}",
            "fundamental_score": "={{ $json.fundamental_score_1_10 }}",
            "fundamental_thesis": "={{ $json.thesis }}"
          },
          "schema": [
            {
              "id": "ticker",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ticker",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ENTER(YES/NO)",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ENTER(YES/NO)",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "ENTRY",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "ENTRY",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "support",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "support",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "resistance",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "resistance",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "stop_loss",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "stop_loss",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "take_profit",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "take_profit",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "tecnhical_tesis",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "tecnhical_tesis",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "fundamental_score",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "fundamental_score",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "negative",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "negative",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "positives",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "positives",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "fundamental_thesis",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "fundamental_thesis",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "518a7800-ba5a-4198-b637-9c506ba1ef20",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1568,
        -928
      ],
      "parameters": {
        "width": 368,
        "height": 1456,
        "content": "## What this workflow does\nThis workflow automates **end-to-end stock analysis** using real market data and AI:\n\n- Reads a list of stock tickers from **Google Sheets**\n- Fetches **fundamental data** (valuation, growth, profitability) and **OHLCV price data** from **EODHD APIs**\n- Computes key **technical indicators** (RSI, SMA 20/50/200, volatility, support & resistance)\n- Uses an **AI model** to generate:\n  - Buy / Watch / Sell recommendation\n  - Entry price, stop-loss, and take-profit levels\n  - Investment thesis, pros & cons\n  - Fundamental quality score (1\u201310)\n- Stores the final structured analysis back into **Google Sheets**\n\nThis creates a **repeatable, no-code stock analysis pipeline** ready for decision-making or dashboards.\n\n### Data source\nMarket data is powered by **EODHD APIs**  \n\ud83d\udc49 Get a **10% discount** using this link:  \nhttps://eodhd.com/pricing-special-10?via=kmg&ref1=Meneses\n\n## How to configure this workflow\n\n### 1. Google Sheets (Input)\nCreate a sheet with a column called:\n- `ticker` (e.g. MSFT, AAPL, AMZN)\n\nEach row represents one stock to analyze.\n\n\n### 2. EODHD APIs\n- Create an EODHD account\n- Get your API token\n- Add it to the HTTP Request nodes as:\n  - `api_token=YOUR_API_KEY`\n\nDiscount link (10% off):  \nhttps://eodhd.com/pricing-special-10?via=kmg&ref1=Meneses\n\n\n### 3. AI Model\n- Configure your AI provider (OpenAI / compatible model)\n- The AI receives:\n  - Fundamentals\n  - Technical indicators\n  - Growth potential score\n- It returns structured JSON with recommendations and trade levels\n\n\n### 4. Google Sheets (Output)\nResults are appended to a `Signals` tab with:\n- Signal (BUY / WATCH / SELL)\n- Entry, Stop Loss, Take Profit\n- Fundamental score (1\u201310)\n- Investment thesis and risk notes\n"
      },
      "typeVersion": 1
    },
    {
      "id": "55aad4d8-f82c-48e9-8b7b-023c4e17d384",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1072,
        -384
      ],
      "parameters": {
        "height": 336,
        "content": "## INPUT\n\n![txt](https://ik.imagekit.io/agbb7sr41/eodhd_input.png)\n"
      },
      "typeVersion": 1
    },
    {
      "id": "a57c2bd7-9f41-459e-9773-a9099c1de4b4",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1488,
        -368
      ],
      "parameters": {
        "color": 7,
        "width": 544,
        "height": 224,
        "content": "## AI Output\n- The merged data is sent to the **AI system**\n- The AI evaluates fundamentals, technicals, and risk\n- The final output includes:\n  - BUY / WATCH / SELL signal\n  - Entry, Stop Loss, Take Profit\n  - Investment score and rationale\n\nResults are ready to use or store in Google Sheets."
      },
      "typeVersion": 1
    },
    {
      "id": "2f850f00-2033-4cd8-95ea-25018a1d85dd",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        224,
        -320
      ],
      "parameters": {
        "color": 7,
        "width": 576,
        "height": 192,
        "content": "##  Transform & Merge\n\n- Raw market data is cleaned and normalized\n- Technical indicators are calculated\n- Fundamentals and price data are **merged into one dataset**\n\nAt the end of this step, each stock has a single, structured data object.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "6d48267a-78c8-4a2f-945e-a51fff6527fa",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 480,
        "height": 192,
        "content": "## Input (Data sources)\n- Stock tickers come from **Google Sheets**\n- Market data is fetched via **API calls**:\n  - Fundamentals\n  - Price candles (OHLCV)\n\nThis step collects all the raw data needed for the analysis."
      },
      "typeVersion": 1
    },
    {
      "id": "98865136-2f82-442d-bb52-6c3972f2fc1f",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        112
      ],
      "parameters": {
        "width": 1008,
        "height": 208,
        "content": "## OUTPUT\n![txt](https://ik.imagekit.io/agbb7sr41/eodhd_ouput.png)"
      },
      "typeVersion": 1
    },
    {
      "id": "26f88d6a-a455-48d5-9152-7e234544f503",
      "name": "Fetch stock fundamentals (EODHD)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -352,
        -24
      ],
      "parameters": {
        "url": "=https://eodhd.com/api/fundamentals/{{ $json.ticker }}.US",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "filter",
              "value": "General,Highlights,Valuation,Technicals"
            },
            {
              "name": "api_token",
              "value": "YOUR_EODHD_API_KEY"
            },
            {
              "name": "fmt",
              "value": "json"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "82c09546-012a-448a-be08-93fdf69069c5",
      "name": "Fetch OHLC price data (EODHD)",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -128,
        -96
      ],
      "parameters": {
        "url": "=https://eodhd.com/api/eod/{{ $json.General.Code }}.US",
        "options": {},
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "from",
              "value": "2023-01-01"
            },
            {
              "name": "to",
              "value": "2025-12-30"
            },
            {
              "name": "period",
              "value": "d"
            },
            {
              "name": "fmt",
              "value": "json"
            },
            {
              "name": "api_token",
              "value": "YOUR_EODHD_API_KEY"
            }
          ]
        }
      },
      "typeVersion": 4.3,
      "alwaysOutputData": true
    },
    {
      "id": "68ab8847-eaab-46c1-9f68-42c92d86765c",
      "name": "Generate AI stock analysis",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        768,
        -24
      ],
      "parameters": {
        "text": "=Analyze the following stock data and return the output strictly following the defined JSON schema.\n\nINPUT DATA:\n{{ JSON.stringify($json) }}\n",
        "options": {
          "systemMessage": "You are a disciplined, quantitative and fundamental equity analyst. Your task is to evaluate a single stock using ONLY the metrics provided in the input (fundamentals, technicals, and growth_potential_score).\n\nRULES:\n- Return ONLY valid JSON. No markdown. No extra text.\n- Do NOT invent data. If a metric is missing, use null and mention the limitation in \"notes\".\n- Use probabilistic, cautious language. Never claim certainty.\n- This is NOT financial advice.\n\nOBJECTIVE:\nProduce a practical, actionable assessment: whether you would enter the trade or not, suggested entry/stop/take-profit levels, and a fundamental quality score (1\u201310) based on ratios, business quality, growth, valuation, and risk.\n\nMETRIC INTERPRETATION GUIDELINES:\n- Technicals:\n  - Support / resistance: use provided levels as the primary reference.\n  - RSI: >70 suggests overbought (higher short-term risk), <30 suggests oversold.\n  - SMA20/50/200: trend strength improves when price > SMA200 and > SMA50.\n  - volatility_ann_pct: high volatility increases risk and lowers confidence.\n- Fundamentals:\n  - High PE penalizes the score unless justified by strong growth.\n  - EPS and YoY revenue/earnings growth support business quality.\n  - Market cap is context only (not a quality metric).\n- growth_potential_score (0\u2013100): use as a composite signal, but do not blindly follow it if it contradicts other indicators.\n\nOUTPUT SCHEMA (MANDATORY):\n{\n  \"ticker\": \"string\",\n  \"decision\": {\n    \"would_enter\": \"YES|NO\",\n    \"signal\": \"BUY|WATCH|SELL\",\n    \"time_horizon\": \"short|medium|long\",\n    \"confidence_0_100\": number\n  },\n  \"trade_plan\": {\n    \"entry\": number,\n    \"support\": number,\n    \"resistance\": number,\n    \"stop_loss\": number,\n    \"take_profit\": number,\n    \"rationale\": \"string\"\n  },\n  \"fundamental_score_1_10\": number,\n  \"fundamental_assessment\": {\n    \"business_quality\": \"weak|average|strong\",\n    \"valuation\": \"cheap|fair|expensive|unknown\",\n    \"growth\": \"low|moderate|high|unknown\",\n    \"profitability\": \"low|moderate|high|unknown\"\n  },\n  \"positives\": [\"string\", \"string\", \"string\"],\n  \"negatives\": [\"string\", \"string\", \"string\"],\n  \"thesis\": [\"string\", \"string\", \"string\"],\n  \"key_metrics_used\": {\n    \"pe\": number,\n    \"forward_pe\": number,\n    \"eps\": number,\n    \"revenue_growth_yoy\": number,\n    \"earnings_growth_yoy\": number,\n    \"rsi_14\": number,\n    \"volatility_ann_pct\": number,\n    \"price_vs_sma200\": \"above|below|unknown\",\n    \"growth_potential_score_0_100\": number\n  },\n  \"notes\": [\"string\"]\n}\n\nTRADE LEVEL LOGIC:\n- support: use technical.support_30d if available; otherwise null.\n- resistance: use technical.resistance_30d if available; otherwise null.\n- entry:\n  - BUY: near support (but not below it).\n  - WATCH: conservative entry (near support or after confirmation).\n  - SELL: entry may be null or near resistance (hypothetical short).\n- stop_loss:\n  - BUY/WATCH: support * 0.97 (\u22483% below support) if support exists;\n    otherwise last_close * 0.93.\n- take_profit:\n  - BUY/WATCH: min(resistance * 0.99, last_close * 1.10) if resistance exists;\n    otherwise last_close * 1.08.\n- If RSI > 75 or volatility_ann_pct > 40, reduce confidence and be conservative with take_profit.\n\nFUNDAMENTAL SCORE (1\u201310):\nEvaluate holistically:\n- Growth (YoY revenue/earnings, EPS quality)\n- Valuation (PE, Forward PE, PEG when available)\n- Business quality and stability\n- Risk (volatility, trend strength)\n- Use growth_potential_score as a tiebreaker\nReturn an integer from 1 to 10.\n\nINPUT:\nYou will receive a JSON object containing fields such as:\n{\n  \"ticker\": \"...\",\n  \"fundamentals\": {...},\n  \"technical\": {...},\n  \"growth_potential_score\": ...\n}\n"
        },
        "promptType": "define"
      },
      "typeVersion": 3
    },
    {
      "id": "0194e08c-d067-40ef-bc35-fdb03e8d5097",
      "name": "Compute indicators and growth score",
      "type": "n8n-nodes-base.code",
      "position": [
        96,
        -96
      ],
      "parameters": {
        "jsCode": "var arr = [];\nfor (var i = 0; i < items.length; i++) {\n  arr.push(items[i].json);\n}\n\n// Sort by date ascending (recommended)\narr.sort(function(a, b) {\n  return String(a.date).localeCompare(String(b.date));\n});\n\nreturn [{ json: { ohlc: arr, ohlc_len: arr.length } }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "13f4df9f-2fca-40b5-9f2f-dacc837b0ad5",
      "name": "Normalize OHLC data",
      "type": "n8n-nodes-base.code",
      "position": [
        544,
        -24
      ],
      "parameters": {
        "jsCode": "// n8n Code node (WAF-safe / ES5)\n// Output: 1 item with fundamentals + OHLCV metrics + growth_potential_score\n// No modern JS: no =>, no ?. , no ??, no backticks, no spread\n\nfunction isArray(x) {\n  return Object.prototype.toString.call(x) === '[object Array]';\n}\nfunction toNum(x) {\n  var n = Number(x);\n  return (isNaN(n) || !isFinite(n)) ? null : n;\n}\nfunction safeStr(x) {\n  if (x === undefined || x === null) return '';\n  return String(x);\n}\nfunction mean(arr) {\n  if (!arr || !arr.length) return null;\n  var s = 0, c = 0;\n  for (var i = 0; i < arr.length; i++) {\n    if (arr[i] !== null) { s += arr[i]; c++; }\n  }\n  return c ? (s / c) : null;\n}\nfunction std(arr) {\n  if (!arr || arr.length < 2) return null;\n  var m = mean(arr);\n  if (m === null) return null;\n  var s = 0, c = 0;\n  for (var i = 0; i < arr.length; i++) {\n    if (arr[i] !== null) {\n      var d = arr[i] - m;\n      s += d * d;\n      c++;\n    }\n  }\n  if (c < 2) return null;\n  return Math.sqrt(s / (c - 1));\n}\nfunction sma(values, period) {\n  if (!values || values.length < period) return null;\n  var sum = 0;\n  for (var i = values.length - period; i < values.length; i++) sum += values[i];\n  return sum / period;\n}\nfunction rsi(values, period) {\n  if (!values || values.length < period + 1) return null;\n  var gains = 0;\n  var losses = 0;\n  for (var i = values.length - period; i < values.length; i++) {\n    var ch = values[i] - values[i - 1];\n    if (ch >= 0) gains += ch;\n    else losses += (-ch);\n  }\n  var avgGain = gains / period;\n  var avgLoss = losses / period;\n  if (avgLoss === 0) return 100;\n  var rs = avgGain / avgLoss;\n  return 100 - (100 / (1 + rs));\n}\nfunction pct(a, b) {\n  if (a === null || b === null || b === 0) return null;\n  return ((a / b) - 1) * 100;\n}\nfunction clamp(x, a, b) {\n  return Math.max(a, Math.min(b, x));\n}\n\n// ------------------------------------------------------------\n// 1) Find fundamentals object (the one that contains General/Highlights)\n// ------------------------------------------------------------\nfunction findFundamentalsObject(allItems) {\n  for (var i = 0; i < allItems.length; i++) {\n    var j = allItems[i].json;\n    if (j && j.General && j.Highlights) return j;\n  }\n  // sometimes fundamentals nested\n  for (var k = 0; k < allItems.length; k++) {\n    var jj = allItems[k].json;\n    if (jj && jj.fundamentals && jj.fundamentals.General) return jj.fundamentals;\n  }\n  return null;\n}\n\n// ------------------------------------------------------------\n// 2) Collect OHLCV candles\n//    Supports these cases:\n//    A) items[] are candles (each item.json has date/open/high/low/close)\n//    B) one item.json has ohlc array: { ohlc: [...] }\n// ------------------------------------------------------------\nfunction collectCandles(allItems) {\n  // Case B: already aggregated\n  for (var i = 0; i < allItems.length; i++) {\n    var j = allItems[i].json;\n    if (j && j.ohlc && isArray(j.ohlc) && j.ohlc.length) return j.ohlc;\n  }\n\n  // Case A: items are candles\n  var candles = [];\n  for (var k = 0; k < allItems.length; k++) {\n    var c = allItems[k].json;\n    if (c && c.date !== undefined && c.close !== undefined) {\n      candles.push(c);\n    }\n  }\n  if (candles.length) return candles;\n\n  return null;\n}\n\n// ------------------------------------------------------------\n// 3) Extract fundamentals fields from your confirmed structure\n// ------------------------------------------------------------\nfunction extractFundamentals(f) {\n  var gen = f.General || {};\n  var hi = f.Highlights || {};\n  var val = f.Valuation || {};\n  var tech = f.Technicals || {};\n\n  var out = {\n    ticker: gen.Code || '',\n    symbol: gen.PrimaryTicker || '',\n    sector: gen.Sector || null,\n    industry: gen.Industry || null,\n\n    market_cap: toNum(hi.MarketCapitalization),\n    pe: toNum(hi.PERatio),\n    forward_pe: toNum(val.ForwardPE),\n    eps: toNum(hi.EarningsShare),\n    eps_ttm: toNum(hi.DilutedEpsTTM),\n    peg: toNum(hi.PEGRatio),\n\n    revenue_ttm: toNum(hi.RevenueTTM),\n    gross_profit_ttm: toNum(hi.GrossProfitTTM),\n\n    q_rev_growth_yoy: (hi.QuarterlyRevenueGrowthYOY !== undefined) ? toNum(hi.QuarterlyRevenueGrowthYOY) : null,\n    q_eps_growth_yoy: (hi.QuarterlyEarningsGrowthYOY !== undefined) ? toNum(hi.QuarterlyEarningsGrowthYOY) : null,\n\n    beta: toNum(tech.Beta),\n    ma_50d: toNum(tech[\"50DayMA\"]),\n    ma_200d: toNum(tech[\"200DayMA\"]),\n    week52_high: toNum(tech[\"52WeekHigh\"]),\n    week52_low: toNum(tech[\"52WeekLow\"])\n  };\n\n  return out;\n}\n\n// ------------------------------------------------------------\n// 4) Compute OHLCV technical metrics\n// ------------------------------------------------------------\nfunction computeTechnicals(candles) {\n  // sort by date asc\n  candles.sort(function(a, b) {\n    return safeStr(a.date).localeCompare(safeStr(b.date));\n  });\n\n  var closes = [];\n  var lows = [];\n  var highs = [];\n  var vols = [];\n  var dates = [];\n\n  for (var i = 0; i < candles.length; i++) {\n    var c = toNum(candles[i].close);\n    if (c === null) continue;\n    closes.push(c);\n    lows.push(toNum(candles[i].low));\n    highs.push(toNum(candles[i].high));\n    vols.push(toNum(candles[i].volume));\n    dates.push(safeStr(candles[i].date));\n  }\n\n  if (closes.length < 60) {\n    return { error: 'Not enough OHLC data', closes_count: closes.length };\n  }\n\n  var lastClose = closes[closes.length - 1];\n  var lastDate = dates[dates.length - 1];\n\n  // returns\n  function closeAt(daysAgo) {\n    var idx = closes.length - 1 - daysAgo;\n    if (idx < 0) return null;\n    return closes[idx];\n  }\n\n  var ret30 = pct(lastClose, closeAt(30));\n  var ret90 = pct(lastClose, closeAt(90));\n\n  // support/resistance 30\n  var start30 = Math.max(0, closes.length - 30);\n  var support30 = null;\n  var resist30 = null;\n  for (var j = start30; j < closes.length; j++) {\n    var lo = lows[j];\n    var hi = highs[j];\n    if (lo !== null) support30 = (support30 === null) ? lo : Math.min(support30, lo);\n    if (hi !== null) resist30 = (resist30 === null) ? hi : Math.max(resist30, hi);\n  }\n\n  // volatility (annualized %)\n  var rets = [];\n  for (var k = 1; k < closes.length; k++) {\n    rets.push((closes[k] / closes[k - 1]) - 1);\n  }\n  var volDaily = std(rets);\n  var volAnnPct = (volDaily === null) ? null : (volDaily * Math.sqrt(252) * 100);\n\n  // RSI/SMA\n  var rsi14 = rsi(closes, 14);\n  var sma20 = sma(closes, 20);\n  var sma50 = sma(closes, 50);\n  var sma200 = sma(closes, 200);\n\n  return {\n    date: lastDate,\n    last_close: lastClose,\n    return_30d_pct: ret30,\n    return_90d_pct: ret90,\n    volatility_ann_pct: volAnnPct,\n    rsi_14: rsi14,\n    sma_20: sma20,\n    sma_50: sma50,\n    sma_200: sma200,\n    support_30d: support30,\n    resistance_30d: resist30\n  };\n}\n\n// ------------------------------------------------------------\n// 5) Growth potential score (0-100) from fundamentals + technicals\n// ------------------------------------------------------------\nfunction growthScore(fund, tech) {\n  var score = 50;\n\n  // Growth (YOY)\n  if (fund.q_rev_growth_yoy !== null) {\n    // 0.18 -> 18 points max 25\n    score += clamp(fund.q_rev_growth_yoy * 100, 0, 25);\n  }\n  if (fund.q_eps_growth_yoy !== null) {\n    score += clamp(fund.q_eps_growth_yoy * 100 * 0.5, 0, 12);\n  }\n\n  // Valuation penalty\n  if (fund.pe !== null) {\n    if (fund.pe > 45) score -= 12;\n    else if (fund.pe > 35) score -= 7;\n    else if (fund.pe < 12) score += 5;\n  }\n\n  // Trend\n  if (tech.sma_50 !== null && tech.last_close > tech.sma_50) score += 6;\n  if (tech.sma_200 !== null && tech.last_close > tech.sma_200) score += 6;\n  if (tech.sma_20 !== null && tech.last_close > tech.sma_20) score += 3;\n\n  // RSI sanity\n  if (tech.rsi_14 !== null) {\n    if (tech.rsi_14 > 75) score -= 5;\n    else if (tech.rsi_14 < 25) score -= 2;\n  }\n\n  // Volatility penalty\n  if (tech.volatility_ann_pct !== null) {\n    if (tech.volatility_ann_pct > 45) score -= 10;\n    else if (tech.volatility_ann_pct > 30) score -= 5;\n  }\n\n  return Math.round(clamp(score, 0, 100));\n}\n\n// ------------------------------------------------------------\n// MAIN\n// ------------------------------------------------------------\nvar fObj = findFundamentalsObject(items);\nvar candles = collectCandles(items);\n\nif (!fObj) {\n  return [{ json: { error: 'Fundamentals not found in items. Check merge.', hint: 'Make sure the fundamentals item with General/Highlights is included.' } }];\n}\nif (!candles) {\n  return [{ json: { error: 'OHLCV candles not found in items.', hint: 'Make sure OHLC items contain date/close or aggregate as {ohlc:[...]}' } }];\n}\n\nvar fundOut = extractFundamentals(fObj);\nvar techOut = computeTechnicals(candles);\n\nif (techOut.error) {\n  return [{ json: { error: techOut.error, closes_count: techOut.closes_count || 0, ticker: fundOut.ticker, symbol: fundOut.symbol } }];\n}\n\nvar gps = growthScore(fundOut, techOut);\n\n// Final payload\nreturn [{\n  json: {\n    ticker: fundOut.ticker,\n    symbol: fundOut.symbol,\n    date: techOut.date,\n    fundamentals: fundOut,\n    technical: techOut,\n    growth_potential_score: gps\n  }\n}];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "9a94c125-9a65-4443-b6f8-4e4b3c938e3a",
      "name": "Prepare data for Google Sheets",
      "type": "n8n-nodes-base.code",
      "position": [
        1344,
        -24
      ],
      "parameters": {
        "jsCode": "var j = items[0].json;\n\nfunction joinArr(a) {\n  if (!a || !a.length) return '';\n  return a.join(' | ');\n}\n\nvar out = {\n  ticker: j.ticker || '',\n  would_enter: j.decision ? j.decision.would_enter : '',\n  signal: j.decision ? j.decision.signal : '',\n  time_horizon: j.decision ? j.decision.time_horizon : '',\n  confidence_0_100: j.decision ? j.decision.confidence_0_100 : null,\n\n  entry: j.trade_plan ? j.trade_plan.entry : null,\n  support: j.trade_plan ? j.trade_plan.support : null,\n  resistance: j.trade_plan ? j.trade_plan.resistance : null,\n  stop_loss: j.trade_plan ? j.trade_plan.stop_loss : null,\n  take_profit: j.trade_plan ? j.trade_plan.take_profit : null,\n  rationale: j.trade_plan ? j.trade_plan.rationale : '',\n\n  fundamental_score_1_10: j.fundamental_score_1_10 !== undefined ? j.fundamental_score_1_10 : null,\n  business_quality: j.fundamental_assessment ? j.fundamental_assessment.business_quality : '',\n  valuation: j.fundamental_assessment ? j.fundamental_assessment.valuation : '',\n  growth: j.fundamental_assessment ? j.fundamental_assessment.growth : '',\n  profitability: j.fundamental_assessment ? j.fundamental_assessment.profitability : '',\n\n  positives: joinArr(j.positives),\n  negatives: joinArr(j.negatives),\n  thesis: joinArr(j.thesis),\n  notes: joinArr(j.notes),\n\n  // key metrics\n  pe: j.key_metrics_used ? j.key_metrics_used.pe : null,\n  forward_pe: j.key_metrics_used ? j.key_metrics_used.forward_pe : null,\n  eps: j.key_metrics_used ? j.key_metrics_used.eps : null,\n  revenue_growth_yoy: j.key_metrics_used ? j.key_metrics_used.revenue_growth_yoy : null,\n  earnings_growth_yoy: j.key_metrics_used ? j.key_metrics_used.earnings_growth_yoy : null,\n  rsi_14: j.key_metrics_used ? j.key_metrics_used.rsi_14 : null,\n  volatility_ann_pct: j.key_metrics_used ? j.key_metrics_used.volatility_ann_pct : null,\n  price_vs_sma200: j.key_metrics_used ? j.key_metrics_used.price_vs_sma200 : '',\n  growth_potential_score_0_100: j.key_metrics_used ? j.key_metrics_used.growth_potential_score_0_100 : null\n};\n\nreturn [{ json: out }];\n"
      },
      "typeVersion": 2
    },
    {
      "id": "b8a74613-dd1e-4016-9531-235547fa16d7",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1152,
        -464
      ],
      "parameters": {
        "color": 7,
        "width": 1136,
        "height": 816,
        "content": ""
      },
      "typeVersion": 1
    },
    {
      "id": "d3948bf0-a52b-4476-bae4-94ee4dac0c3a",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        16,
        -472
      ],
      "parameters": {
        "color": 7,
        "width": 1280,
        "height": 816,
        "content": ""
      },
      "typeVersion": 1
    },
    {
      "id": "6a6233df-23d1-4a68-a3eb-4be88087a17a",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1312,
        -496
      ],
      "parameters": {
        "color": 7,
        "width": 1504,
        "height": 864,
        "content": ""
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "1fc98b75-75d5-4d52-8020-038fc4e350ec",
  "connections": {
    "Merge": {
      "main": [
        [
          {
            "node": "Normalize OHLC data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items": {
      "main": [
        [],
        [
          {
            "node": "Fetch stock fundamentals (EODHD)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Generate AI stock analysis",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Append row in sheet": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code in JavaScript2": {
      "main": [
        [
          {
            "node": "Prepare data for Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get row(s) in sheet": {
      "main": [
        [
          {
            "node": "Loop Over Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize OHLC data": {
      "main": [
        [
          {
            "node": "Generate AI stock analysis",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate AI stock analysis": {
      "main": [
        [
          {
            "node": "Code in JavaScript2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch OHLC price data (EODHD)": {
      "main": [
        [
          {
            "node": "Compute indicators and growth score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare data for Google Sheets": {
      "main": [
        [
          {
            "node": "Append row in sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch stock fundamentals (EODHD)": {
      "main": [
        [
          {
            "node": "Fetch OHLC price data (EODHD)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Compute indicators and growth score": {
      "main": [
        [
          {
            "node": "Merge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking \u2018Execute workflow\u2019": {
      "main": [
        [
          {
            "node": "Get row(s) in sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}