This workflow corresponds to n8n.io template #16443 — we link there as the canonical source.
This workflow follows the Gmail → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "H6sBHKk4k7LgkpVJ",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "Portfolio Risk Analyzer",
"tags": [],
"nodes": [
{
"id": "17b29797-443f-4361-9fd5-aa1a4213f373",
"name": "Receive Portfolio Request",
"type": "n8n-nodes-base.webhook",
"position": [
1616,
1376
],
"parameters": {
"path": "portfolio-analyze",
"options": {
"allowedOrigins": "*"
},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2
},
{
"id": "408dea81-d832-4821-ae25-5bb9d6ee0edc",
"name": "Choose Portfolio Data Source",
"type": "n8n-nodes-base.if",
"position": [
1840,
1376
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond_csv",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.body.source }}",
"rightValue": "csv"
}
]
}
},
"typeVersion": 2
},
{
"id": "bb466a6d-e74b-4c11-aa83-4ceacdc1a648",
"name": "Prepare Uploaded CSV File",
"type": "n8n-nodes-base.code",
"position": [
2064,
1280
],
"parameters": {
"jsCode": "const body = $json.body || {};\nconst query = $json.query || {};\nconst headers = $json.headers || {};\n\nconst base64 = body.fileData || body.csvBase64 || body.file || '';\n\nif (!base64) {\n throw new Error('CSV source selected but no fileData found in request body. Expected body.fileData as base64 CSV content.');\n}\n\nreturn [\n {\n json: {\n ...$json,\n userEmail: body.userEmail || query.userEmail || headers['x-user-email'] || headers['X-User-Email'] || '',\n userName: body.userName || query.userName || 'Investor'\n },\n binary: {\n data: {\n data: base64,\n mimeType: 'text/csv',\n fileName: body.fileName || 'portfolio.csv'\n }\n }\n }\n];"
},
"typeVersion": 2
},
{
"id": "9fc5855b-5993-49b2-b403-433685fd84e1",
"name": "Read Portfolio CSV Data",
"type": "n8n-nodes-base.spreadsheetFile",
"position": [
2288,
1280
],
"parameters": {
"options": {}
},
"typeVersion": 2
},
{
"id": "05b73d5e-f319-4a6c-a9c6-9968d6c3a1b8",
"name": "Load Portfolio from Google Sheets",
"type": "n8n-nodes-base.googleSheets",
"position": [
2288,
1472
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1Xvxvgg1dt0KdHAoG80hDxB8-td0_si6WyOHJGcUYsS8/edit#gid=0",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1Xvxvgg1dt0KdHAoG80hDxB8-td0_si6WyOHJGcUYsS8",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1Xvxvgg1dt0KdHAoG80hDxB8-td0_si6WyOHJGcUYsS8/edit?usp=drivesdk",
"cachedResultName": "Holdings"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.5
},
{
"id": "7ccbf5ae-7f46-469f-a7c6-8385a85ad2e5",
"name": "Combine Portfolio Holdings",
"type": "n8n-nodes-base.aggregate",
"position": [
2512,
1376
],
"parameters": {
"options": {},
"aggregate": "aggregateAllItemData"
},
"typeVersion": 1
},
{
"id": "0513d14c-28c7-4bbe-bf4d-6eea9331cde8",
"name": "Set User & Source Details",
"type": "n8n-nodes-base.set",
"position": [
2736,
1376
],
"parameters": {
"values": {
"string": [
{
"name": "user_name",
"value": "={{ $json.userName || 'Investor' }}"
},
{
"name": "user_email",
"value": "={{ $json.userEmail || 'fallback@example.com' }}"
},
{
"name": "source_type",
"value": "={{ $json.body?.source || 'unknown' }}"
}
]
},
"options": {}
},
"typeVersion": 2
},
{
"id": "7e45157b-7686-4528-92a0-793ea2c081b9",
"name": "Analyze Portfolio Risk",
"type": "n8n-nodes-base.code",
"position": [
2960,
1376
],
"parameters": {
"jsCode": "const allItems = $input.all();\nlet rows = [];\n\nallItems.forEach(item => {\n const d = item.json;\n\n if (Array.isArray(d.data)) {\n rows = rows.concat(d.data);\n } else if (typeof d === 'object' && d.ticker) {\n rows.push(d);\n } else if (typeof d === 'object' && d !== null) {\n Object.keys(d).forEach(k => {\n if (Array.isArray(d[k])) rows = rows.concat(d[k]);\n });\n }\n});\n\nif (rows.length === 0) {\n rows = allItems.map(i => i.json);\n}\n\nconst normalize = (v) => (v ?? '').toString().trim();\n\nconst holdings = rows.map(r => {\n const qty = parseFloat(r.quantity) || 0;\n const avgBuy = parseFloat(r.avg_buy_price) || 0;\n const curPrice = parseFloat(r.current_price) || 0;\n const marketVal = qty * curPrice;\n const costBasis = qty * avgBuy;\n const pnl = marketVal - costBasis;\n const pnlPct = costBasis > 0 ? ((pnl / costBasis) * 100) : 0;\n\n return {\n ticker: normalize(r.ticker || r.symbol || r.Ticker || r.Symbol) || 'UNKNOWN',\n company: normalize(r.company || r.Company || r.name || r.ticker) || 'UNKNOWN',\n sector: normalize(r.sector || r.Sector) || 'Unclassified',\n asset_class: normalize(r.asset_class || r.assetClass || r['asset class'] || r.AssetClass) || 'Equity',\n quantity: qty,\n avg_buy_price: avgBuy,\n current_price: curPrice,\n market_value: +marketVal.toFixed(2),\n cost_basis: +costBasis.toFixed(2),\n pnl: +pnl.toFixed(2),\n pnl_pct: +pnlPct.toFixed(2)\n };\n}).filter(h => h.ticker !== 'UNKNOWN' && h.market_value > 0);\n\nconst totalValue = holdings.reduce((s, h) => s + h.market_value, 0);\nconst totalCost = holdings.reduce((s, h) => s + h.cost_basis, 0);\nconst totalPnl = totalValue - totalCost;\nconst totalPnlPct = totalCost > 0 ? ((totalPnl / totalCost) * 100) : 0;\n\nconst holdingsWithAlloc = holdings.map(h => ({\n ...h,\n allocation_pct: totalValue > 0 ? +((h.market_value / totalValue) * 100).toFixed(2) : 0\n}));\n\nconst sectorMap = {};\nholdingsWithAlloc.forEach(h => {\n if (!sectorMap[h.sector]) sectorMap[h.sector] = { market_value: 0, holdings: [] };\n sectorMap[h.sector].market_value += h.market_value;\n sectorMap[h.sector].holdings.push(h.ticker);\n});\n\nconst sectorBreakdown = Object.entries(sectorMap).map(([sector, data]) => ({\n sector,\n market_value: +data.market_value.toFixed(2),\n allocation_pct: totalValue > 0 ? +((data.market_value / totalValue) * 100).toFixed(2) : 0,\n tickers: data.holdings\n})).sort((a, b) => b.allocation_pct - a.allocation_pct);\n\nconst acMap = {};\nholdingsWithAlloc.forEach(h => {\n acMap[h.asset_class] = (acMap[h.asset_class] || 0) + h.market_value;\n});\n\nconst assetClassBreakdown = Object.entries(acMap).map(([ac, val]) => ({\n asset_class: ac,\n market_value: +val.toFixed(2),\n allocation_pct: totalValue > 0 ? +((val / totalValue) * 100).toFixed(2) : 0\n})).sort((a, b) => b.allocation_pct - a.allocation_pct);\n\nconst SINGLE_STOCK_THRESHOLD = 15;\nconst SECTOR_THRESHOLD = 35;\nconst TOP3_THRESHOLD = 60;\n\nconst overweightStocks = holdingsWithAlloc.filter(h => h.allocation_pct > SINGLE_STOCK_THRESHOLD);\nconst overweightSectors = sectorBreakdown.filter(s => s.allocation_pct > SECTOR_THRESHOLD);\n\nconst top3Value = [...holdingsWithAlloc]\n .sort((a, b) => b.market_value - a.market_value)\n .slice(0, 3)\n .reduce((s, h) => s + h.allocation_pct, 0);\n\nconst top3ConcentrationRisk = top3Value > TOP3_THRESHOLD;\n\nlet riskScore = 0;\nriskScore += Math.min(overweightStocks.length * 10, 30);\nriskScore += Math.min(overweightSectors.length * 15, 30);\nriskScore += top3ConcentrationRisk ? 20 : 0;\nriskScore += holdingsWithAlloc.length < 5 ? 20 : (holdingsWithAlloc.length < 10 ? 10 : 0);\n\nconst riskLabel = riskScore >= 60 ? 'HIGH' : riskScore >= 30 ? 'MEDIUM' : 'LOW';\n\nconst sortedByPnl = [...holdingsWithAlloc].sort((a, b) => b.pnl_pct - a.pnl_pct);\nconst topGainers = sortedByPnl.slice(0, 3);\nconst topLosers = sortedByPnl.slice(-3).reverse();\n\nreturn [{\n json: {\n portfolio_summary: {\n total_market_value: +totalValue.toFixed(2),\n total_cost_basis: +totalCost.toFixed(2),\n total_pnl: +totalPnl.toFixed(2),\n total_pnl_pct: +totalPnlPct.toFixed(2),\n holding_count: holdingsWithAlloc.length,\n risk_score: riskScore,\n risk_label: riskLabel,\n analysis_timestamp: new Date().toISOString()\n },\n holdings: holdingsWithAlloc,\n sector_breakdown: sectorBreakdown,\n asset_class_breakdown: assetClassBreakdown,\n risk_flags: {\n overweight_stocks: overweightStocks.map(h => ({ ticker: h.ticker, allocation_pct: h.allocation_pct })),\n overweight_sectors: overweightSectors.map(s => ({ sector: s.sector, allocation_pct: s.allocation_pct })),\n top3_concentration_pct: +top3Value.toFixed(2),\n top3_concentration_risk: top3ConcentrationRisk,\n single_stock_threshold: SINGLE_STOCK_THRESHOLD,\n sector_threshold: SECTOR_THRESHOLD\n },\n top_gainers: topGainers.map(h => ({ ticker: h.ticker, pnl_pct: h.pnl_pct, pnl: h.pnl })),\n top_losers: topLosers.map(h => ({ ticker: h.ticker, pnl_pct: h.pnl_pct, pnl: h.pnl }))\n }\n}];"
},
"typeVersion": 2
},
{
"id": "12314e5e-dd61-4b6c-be76-fe3f4adad782",
"name": "Check Portfolio Data",
"type": "n8n-nodes-base.if",
"position": [
3184,
1376
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond_valid",
"operator": {
"type": "number",
"operation": "gt"
},
"leftValue": "={{ $json.portfolio_summary.holding_count }}",
"rightValue": 0
}
]
}
},
"typeVersion": 2
},
{
"id": "77e4e4bb-f2c1-4b11-8903-8a8f30622c4b",
"name": "Generate AI Risk Insights",
"type": "@n8n/n8n-nodes-langchain.openAi",
"position": [
3408,
1200
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini",
"cachedResultName": "GPT-4O-MINI"
},
"options": {
"maxTokens": 2000,
"temperature": 0.3
},
"messages": {
"values": [
{
"role": "system",
"content": "=You are a senior portfolio risk analyst with CFA designation. Analyze the provided portfolio data and generate a professional, actionable risk report. Be specific, data-driven, and concise. Use bullet points where helpful. Never make specific buy/sell recommendations \u2014 provide analysis only. Format output as clean HTML suitable for an email body (use <h2>, <p>, <ul>, <li>, <strong>, <span style='color:#e74c3c'> for warnings, <span style='color:#27ae60'> for positives). Do NOT include <html>, <head>, <body> tags."
},
{
"content": "=You are a senior portfolio risk analyst with CFA-level expertise. Analyze the provided portfolio data and generate a professional, concise, and data-driven portfolio risk report.\n\nYour output must be written as CLEAN, VALID HTML suitable for inserting directly into an email body.\n\nSTRICT OUTPUT RULES:\n- Output ONLY the final HTML report\n- Do NOT include markdown\n- Do NOT use triple backticks\n- Do NOT include JSON\n- Do NOT include explanations before or after the HTML\n- Do NOT include <html>, <head>, or <body> tags\n- Use only these HTML tags: <h2>, <p>, <ul>, <li>, <strong>, <span>\n- Ensure ALL HTML tags are properly opened and closed\n- Keep formatting clean and professional\n- Use:\n - <span style='color:#e74c3c'> for warnings / elevated risk\n - <span style='color:#27ae60'> for positive observations\n\nANALYSIS RULES:\n- Be specific and reference exact values, percentages, counts, and concentrations from the data\n- Be concise but insightful\n- Avoid generic filler language\n- Never make direct buy/sell recommendations\n- Do not mention that you are an AI\n- If data is missing for any section, state that clearly and continue\n\nTONE:\n- Professional\n- Analytical\n- Client-facing\n- Actionable but not advisory\n\nAnalyze this portfolio and generate a comprehensive risk report.\n\nPORTFOLIO SUMMARY:\n{{ JSON.stringify($json.portfolio_summary, null, 2) }}\n\nSECTOR BREAKDOWN:\n{{ JSON.stringify($json.sector_breakdown, null, 2) }}\n\nASSET CLASS BREAKDOWN:\n{{ JSON.stringify($json.asset_class_breakdown, null, 2) }}\n\nRISK FLAGS:\n{{ JSON.stringify($json.risk_flags, null, 2) }}\n\nTOP GAINERS:\n{{ JSON.stringify($json.top_gainers, null, 2) }}\n\nTOP LOSERS:\n{{ JSON.stringify($json.top_losers, null, 2) }}\n\nHOLDINGS DETAIL (all positions):\n{{ JSON.stringify($json.holdings, null, 2) }}\n\nStructure the report exactly in this order:\n\n<h2>Executive Summary</h2>\n- 2 to 3 concise sentences summarizing overall risk, diversification, and performance\n\n<h2>Sector Concentration Analysis</h2>\n- Identify overexposed sectors with exact percentages\n- Highlight any sector exceeding prudent diversification thresholds\n\n<h2>Risk Concentration Issues</h2>\n- Evaluate:\n - single-stock concentration\n - top-3 holdings concentration\n - underdiversification\n - any notable concentration or correlation risks\n\n<h2>Performance Highlights</h2>\n- Summarize strongest and weakest contributors\n- Include exact returns / PnL where relevant\n- Add brief context only if clearly supported by portfolio composition\n\n<h2>Asset Class Diversification Assessment</h2>\n- Evaluate diversification across equity, fixed income, commodities, cash, or other asset classes\n- Highlight imbalances clearly\n\n<h2>Key Risk Mitigation Suggestions</h2>\n- Provide 3 to 5 portfolio-structure suggestions focused on diversification, concentration reduction, or risk balancing\n- Do NOT tell the client to buy or sell specific securities\n\n<h2>Overall Risk Rating Explanation</h2>\n- Explain why this portfolio deserves a risk score of {{ $json.portfolio_summary.risk_score }}/100 and risk label {{ $json.portfolio_summary.risk_label }}\n- Tie the explanation directly to the data above"
}
]
}
},
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.8
},
{
"id": "4079fc49-ee1b-4094-8ba9-b75e013ad842",
"name": "Build Portfolio Email Report",
"type": "n8n-nodes-base.code",
"position": [
3760,
1200
],
"parameters": {
"jsCode": "const data = $('Analyze Portfolio Risk').first().json;\nconst webhook = $('Receive Portfolio Request').first().json;\n\nconst aiRaw = $input.first().json;\nconst aiText = aiRaw?.message?.content\n || aiRaw?.choices?.[0]?.message?.content\n || aiRaw?.output?.[0]?.content?.[0]?.text\n || aiRaw?.text\n || 'AI insights unavailable.';\n\nconst body = webhook.body || {};\nconst query = webhook.query || {};\nconst headers = webhook.headers || {};\n\nconst userName = body.userName || query.userName || 'Investor';\nconst userEmail = body.userEmail || query.userEmail || headers['x-user-email'] || headers['X-User-Email'] || 'fallback@example.com';\n\nconst summary = data.portfolio_summary;\nconst riskColor = summary.risk_label === 'HIGH' ? '#e74c3c' : summary.risk_label === 'MEDIUM' ? '#f39c12' : '#27ae60';\n\nconst fmtCurrency = (n) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(Number(n || 0));\nconst fmtPct = (n) => `${Number(n || 0) > 0 ? '+' : ''}${Number(n || 0)}%`;\nconst pnlColor = (n) => Number(n || 0) >= 0 ? '#27ae60' : '#e74c3c';\n\nconst sectorRows = data.sector_breakdown.map(s => `\n <tr>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee\">${s.sector}</td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;text-align:right\">${fmtCurrency(s.market_value)}</td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;text-align:right\">\n <span style=\"color:${s.allocation_pct > 35 ? '#e74c3c' : '#333'};font-weight:${s.allocation_pct > 35 ? 'bold' : 'normal'}\">${s.allocation_pct}%${s.allocation_pct > 35 ? ' ' : ''}</span>\n </td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;font-size:12px;color:#666\">${(s.tickers || []).join(', ')}</td>\n </tr>`).join('');\n\nconst holdingRows = [...data.holdings]\n .sort((a, b) => b.market_value - a.market_value)\n .slice(0, 10)\n .map(h => `\n <tr>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee\"><strong>${h.ticker}</strong></td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;color:#666;font-size:12px\">${h.sector}</td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;text-align:right\">${fmtCurrency(h.market_value)}</td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;text-align:right\">${h.allocation_pct}%</td>\n <td style=\"padding:8px 12px;border-bottom:1px solid #eee;text-align:right;color:${pnlColor(h.pnl)}\">${fmtCurrency(h.pnl)} (${fmtPct(h.pnl_pct)})</td>\n </tr>`).join('');\n\nconst riskAlertBanner = (data.risk_flags.overweight_stocks.length > 0 || data.risk_flags.overweight_sectors.length > 0 || data.risk_flags.top3_concentration_risk) ? `\n <div style=\"margin:24px 32px;background:#fff5f5;border:1px solid #fed7d7;border-left:4px solid #e74c3c;border-radius:8px;padding:16px 20px\">\n <div style=\"font-weight:700;color:#c53030;margin-bottom:8px\" Risk Concentration Alerts</div>\n ${data.risk_flags.overweight_stocks.map(s => `<div style=\"color:#742a2a;font-size:13px;margin-bottom:4px\">\u2022 <strong>${s.ticker}</strong> represents ${s.allocation_pct}% of portfolio (threshold: ${data.risk_flags.single_stock_threshold}%)</div>`).join('')}\n ${data.risk_flags.overweight_sectors.map(s => `<div style=\"color:#742a2a;font-size:13px;margin-bottom:4px\">\u2022 <strong>${s.sector}</strong> sector is ${s.allocation_pct}% of portfolio (threshold: ${data.risk_flags.sector_threshold}%)</div>`).join('')}\n ${data.risk_flags.top3_concentration_risk ? `<div style=\"color:#742a2a;font-size:13px\">\u2022 Top 3 holdings = ${data.risk_flags.top3_concentration_pct}% of portfolio (threshold: 60%)</div>` : ''}\n </div>` : '';\n\nconst html = `<!DOCTYPE html>\n<html>\n<head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f6f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif\">\n<div style=\"max-width:700px;margin:30px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08)\">\n\n <div style=\"background:linear-gradient(135deg,#1a1a2e 0%,#16213e 50%,#0f3460 100%);padding:36px 40px;color:#fff\">\n <div style=\"font-size:12px;text-transform:uppercase;letter-spacing:2px;color:#a0aec0;margin-bottom:8px\">Portfolio Risk Report</div>\n <h1 style=\"margin:0 0 4px;font-size:26px;font-weight:700\">Portfolio Analysis</h1>\n <p style=\"margin:0;color:#a0aec0;font-size:14px\">Generated ${new Date(summary.analysis_timestamp).toLocaleString('en-US', {dateStyle:'full',timeStyle:'short'})} \u00b7 For ${userName}</p>\n </div>\n\n <div style=\"display:flex;background:#f8fafc;border-bottom:1px solid #e2e8f0\">\n <div style=\"flex:1;padding:20px;text-align:center;border-right:1px solid #e2e8f0\">\n <div style=\"font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#718096;margin-bottom:4px\">Portfolio Value</div>\n <div style=\"font-size:22px;font-weight:700;color:#1a202c\">${fmtCurrency(summary.total_market_value)}</div>\n </div>\n <div style=\"flex:1;padding:20px;text-align:center;border-right:1px solid #e2e8f0\">\n <div style=\"font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#718096;margin-bottom:4px\">Total P&L</div>\n <div style=\"font-size:22px;font-weight:700;color:${pnlColor(summary.total_pnl)}\">${fmtCurrency(summary.total_pnl)} (${fmtPct(summary.total_pnl_pct)})</div>\n </div>\n <div style=\"flex:1;padding:20px;text-align:center;border-right:1px solid #e2e8f0\">\n <div style=\"font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#718096;margin-bottom:4px\">Holdings</div>\n <div style=\"font-size:22px;font-weight:700;color:#1a202c\">${summary.holding_count}</div>\n </div>\n <div style=\"flex:1;padding:20px;text-align:center\">\n <div style=\"font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#718096;margin-bottom:4px\">Risk Level</div>\n <div style=\"font-size:18px;font-weight:700;color:${riskColor}\">${summary.risk_label} <span style=\"font-size:12px;background:${riskColor}20;color:${riskColor};padding:2px 8px;border-radius:99px\">${summary.risk_score}/100</span></div>\n </div>\n </div>\n\n ${riskAlertBanner}\n\n <div style=\"padding:24px 32px\">\n <h2 style=\"margin:0 0 16px;font-size:18px;color:#1a202c;border-bottom:2px solid #e2e8f0;padding-bottom:12px\"> AI Risk Analysis</h2>\n <div style=\"color:#2d3748;font-size:14px;line-height:1.7\">${aiText}</div>\n </div>\n\n <div style=\"padding:0 32px 24px\">\n <h2 style=\"margin:0 0 16px;font-size:18px;color:#1a202c;border-bottom:2px solid #e2e8f0;padding-bottom:12px\"> Sector Breakdown</h2>\n <table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n <thead>\n <tr style=\"background:#f7fafc\">\n <th style=\"padding:10px 12px;text-align:left;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Sector</th>\n <th style=\"padding:10px 12px;text-align:right;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Market Value</th>\n <th style=\"padding:10px 12px;text-align:right;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Allocation</th>\n <th style=\"padding:10px 12px;text-align:left;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Tickers</th>\n </tr>\n </thead>\n <tbody>${sectorRows}</tbody>\n </table>\n </div>\n\n <div style=\"padding:0 32px 24px\">\n <h2 style=\"margin:0 0 16px;font-size:18px;color:#1a202c;border-bottom:2px solid #e2e8f0;padding-bottom:12px\"> Top 10 Holdings</h2>\n <table style=\"width:100%;border-collapse:collapse;font-size:13px\">\n <thead>\n <tr style=\"background:#f7fafc\">\n <th style=\"padding:10px 12px;text-align:left;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Ticker</th>\n <th style=\"padding:10px 12px;text-align:left;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Sector</th>\n <th style=\"padding:10px 12px;text-align:right;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Market Value</th>\n <th style=\"padding:10px 12px;text-align:right;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">Weight</th>\n <th style=\"padding:10px 12px;text-align:right;color:#718096;font-weight:600;border-bottom:2px solid #e2e8f0\">P&L</th>\n </tr>\n </thead>\n <tbody>${holdingRows}</tbody>\n </table>\n </div>\n\n <div style=\"background:#f8fafc;padding:20px 32px;border-top:1px solid #e2e8f0\">\n <p style=\"margin:0;font-size:11px;color:#a0aec0;line-height:1.6\"> <strong>Disclaimer:</strong> This report is generated by an automated AI system for informational purposes only. It does not constitute financial advice. Past performance is not indicative of future results.</p>\n <p style=\"margin:8px 0 0;font-size:11px;color:#a0aec0\">Generated by Portfolio Risk Analyzer \u00b7 ${new Date().toISOString()}</p>\n </div>\n\n</div>\n</body>\n</html>`;\n\nreturn [{\n json: {\n html_email: html,\n user_email: userEmail,\n user_name: userName,\n risk_label: summary.risk_label,\n risk_score: summary.risk_score,\n total_value: summary.total_market_value,\n total_pnl: summary.total_pnl,\n report_id: `RPT-${Date.now()}`\n }\n}];"
},
"typeVersion": 2
},
{
"id": "fbbf2017-09cd-4602-95d0-58f5c4c65056",
"name": "Send Portfolio Report Email",
"type": "n8n-nodes-base.gmail",
"position": [
3984,
1200
],
"parameters": {
"sendTo": "={{ $json.user_email }}",
"message": "={{ $json.html_email }}",
"options": {
"appendAttribution": false
},
"subject": "={{ ' Portfolio Risk Report \u2014 ' + $json.risk_label + ' Risk | $' + Number($json.total_value || 0).toLocaleString() + ' | ' + new Date().toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'}) }}"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.2
},
{
"id": "c90118f3-c547-4ebf-ad77-255328542294",
"name": "Return Success Response",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
4208,
1200
],
"parameters": {
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ success: true, report_id: $('Build Portfolio Email Report').first().json.report_id, message: 'Portfolio analysis complete. Report processed successfully.', sent_to: $('Build Portfolio Email Report').first().json.user_email, risk_label: $('Build Portfolio Email Report').first().json.risk_label, risk_score: $('Build Portfolio Email Report').first().json.risk_score, total_value: $('Build Portfolio Email Report').first().json.total_value }) }}"
},
"typeVersion": 1.1
},
{
"id": "de1ad1b0-0ce6-47b3-b769-642ae15077b1",
"name": "Check User Email for Error Alert",
"type": "n8n-nodes-base.if",
"position": [
3472,
1552
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"operator": {
"type": "boolean",
"operation": "true"
},
"leftValue": "={{ !!($('Receive Portfolio Request').first().json.body?.userEmail || $('Receive Portfolio Request').first().json.query?.userEmail || $('Receive Portfolio Request').first().json.headers?.['x-user-email']) }}",
"rightValue": true
}
]
}
},
"typeVersion": 2
},
{
"id": "31fccc6f-7a75-4871-8d95-3f90f7028214",
"name": "Send Invalid Portfolio Alert",
"type": "n8n-nodes-base.gmail",
"position": [
3808,
1504
],
"parameters": {
"sendTo": "={{ $('Receive Portfolio Request').first().json.body?.userEmail || $('Receive Portfolio Request').first().json.query?.userEmail || $('Receive Portfolio Request').first().json.headers?.['x-user-email'] }}",
"message": "<p>Hi,</p><p>We were unable to process your portfolio file. The uploaded CSV or Google Sheet appears to be empty or incorrectly formatted.</p><p><strong>Required columns:</strong> ticker, company, sector, quantity, avg_buy_price, current_price, asset_class</p><p>Please check your file and try again.</p><p>\u2014 Portfolio Risk Analyzer</p>",
"options": {
"appendAttribution": false
},
"subject": " Portfolio Analysis Failed \u2014 Empty or Invalid File"
},
"credentials": {
"gmailOAuth2": {
"name": "<your credential>"
}
},
"typeVersion": 2.2
},
{
"id": "a4ac99a3-3ea3-43f7-9c19-536dbb132feb",
"name": "Return Validation Error",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
4192,
1616
],
"parameters": {
"options": {
"responseCode": 422,
"responseHeaders": {
"entries": [
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"respondWith": "json",
"responseBody": "={ \"success\": false, \"error\": \"Portfolio data is empty or invalid. Please check your CSV columns: ticker, company, sector, quantity, avg_buy_price, current_price, asset_class\" }"
},
"typeVersion": 1.1
},
{
"id": "b1d959a7-ebe9-44bb-9675-f8cd1cf73ce6",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
480,
16
],
"parameters": {
"width": 816,
"height": 1056,
"content": "## AI Portfolio Analysis Workflow\n\nAutomates portfolio ingestion, validation, risk analysis, AI insights generation, and reporting using Webhook, CSV/Google Sheets, AI, and Email\n\n## Flow:\n\nWebhook \u2192 Select Data Source \u2192 Parse Data \u2192 Merge Holdings \u2192 Set Metadata \u2192 Analyze Risk \u2192 Validate Portfolio\n\n\u2192 Valid \u2192 Generate AI Insights \u2192 Build Report \u2192 Send Email \u2192 API Response\n\u2192 Invalid \u2192 Check Email \u2192 Send Alert \u2192 Error Response\n\n## Setup Steps\n\nConfigure the Webhook to receive portfolio input (CSV upload or request body).\nEnsure CSV structure and Google Sheet contain required fields (stock, quantity, sector, etc.).\n\nSet up credentials for:\n\nGoogle Sheets\nEmail service (Gmail)\nAI node (for insights generation)\n\nAdd a Set node to standardize:\n\nUser details (email, name)\nData source (CSV / Sheets)\nPortfolio metadata\n\nVerify:\n\nField mappings across CSV/Sheets\nValidation logic before analysis\nEmail formatting and delivery steps\n\n## How It Works\n\nPortfolio data is submitted via Webhook and routed based on source (CSV or Google Sheets).\nThe system parses and combines holdings into a unified structure, then enriches it with user metadata.\n\nRisk analysis calculates allocation, sector exposure, and concentration.\nA validation step ensures the portfolio is usable.\n\nFor valid portfolios: AI generates insights (risk, diversification, exposure), builds a report, and sends it via email.\nFor invalid portfolios: the system sends an alert email and returns an error response."
},
"typeVersion": 1
},
{
"id": "87b25d34-8f20-4231-b1be-d3ad3c04f5dc",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
1552,
992
],
"parameters": {
"color": 7,
"width": 432,
"height": 768,
"content": "## Portfolio Request Intake\n\nTriggers the workflow via a Webhook and captures incoming portfolio requests, allowing users to submit data (CSV or API payload) that initiates the end-to-end portfolio analysis process without manual intervention."
},
"typeVersion": 1
},
{
"id": "8b28f661-73a7-4553-bcdf-6f76b1ea8b3a",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
2032,
992
],
"parameters": {
"color": 7,
"width": 416,
"height": 768,
"content": "## Portfolio Data Preparation & Loading\n\nProcesses the selected data source by preparing uploaded CSV files or fetching records from Google Sheets, then converts the data into a structured format for downstream portfolio analysis and processing."
},
"typeVersion": 1
},
{
"id": "aa7379b6-2ccb-40f4-ba44-93749bf6adf0",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
2496,
992
],
"parameters": {
"color": 7,
"width": 800,
"height": 768,
"content": "## Portfolio Processing & Risk Validation\n\nCombines portfolio holdings into a unified structure, enriches the data with user and source details, performs risk and allocation analysis, and validates the portfolio to ensure it meets the required criteria before proceeding to insights or error handling."
},
"typeVersion": 1
},
{
"id": "466f863f-02b6-42b2-8560-a3f861b78336",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
3360,
992
],
"parameters": {
"color": 7,
"width": 1024,
"height": 384,
"content": "## AI Processing & Response Handling\n\nExecutes custom logic and AI-driven analysis to generate portfolio insights, sends the final report or alerts via email, and returns the workflow outcome to the user through a webhook response.\n"
},
"typeVersion": 1
},
{
"id": "786b8f4a-62b6-412b-a8d3-0e03ab6f6c05",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
3360,
1392
],
"parameters": {
"color": 7,
"width": 1024,
"height": 352,
"content": "## Error Handling & Alerting\n\nValidates user contact details, sends failure notifications via email, and returns an error response when portfolio data does not meet required criteria.\n"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"availableInMCP": false,
"executionOrder": "v1"
},
"versionId": "1a37912f-f568-455b-9d68-942f20a509df",
"connections": {
"Check Portfolio Data": {
"main": [
[
{
"node": "Generate AI Risk Insights",
"type": "main",
"index": 0
}
],
[
{
"node": "Check User Email for Error Alert",
"type": "main",
"index": 0
}
]
]
},
"Analyze Portfolio Risk": {
"main": [
[
{
"node": "Check Portfolio Data",
"type": "main",
"index": 0
}
]
]
},
"Read Portfolio CSV Data": {
"main": [
[
{
"node": "Combine Portfolio Holdings",
"type": "main",
"index": 0
}
]
]
},
"Generate AI Risk Insights": {
"main": [
[
{
"node": "Build Portfolio Email Report",
"type": "main",
"index": 0
}
]
]
},
"Prepare Uploaded CSV File": {
"main": [
[
{
"node": "Read Portfolio CSV Data",
"type": "main",
"index": 0
}
]
]
},
"Receive Portfolio Request": {
"main": [
[
{
"node": "Choose Portfolio Data Source",
"type": "main",
"index": 0
}
]
]
},
"Set User & Source Details": {
"main": [
[
{
"node": "Analyze Portfolio Risk",
"type": "main",
"index": 0
}
]
]
},
"Combine Portfolio Holdings": {
"main": [
[
{
"node": "Set User & Source Details",
"type": "main",
"index": 0
}
]
]
},
"Send Portfolio Report Email": {
"main": [
[
{
"node": "Return Success Response",
"type": "main",
"index": 0
}
]
]
},
"Build Portfolio Email Report": {
"main": [
[
{
"node": "Send Portfolio Report Email",
"type": "main",
"index": 0
}
]
]
},
"Choose Portfolio Data Source": {
"main": [
[
{
"node": "Prepare Uploaded CSV File",
"type": "main",
"index": 0
}
],
[
{
"node": "Load Portfolio from Google Sheets",
"type": "main",
"index": 0
}
]
]
},
"Send Invalid Portfolio Alert": {
"main": [
[
{
"node": "Return Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Check User Email for Error Alert": {
"main": [
[
{
"node": "Send Invalid Portfolio Alert",
"type": "main",
"index": 0
}
],
[
{
"node": "Return Validation Error",
"type": "main",
"index": 0
}
]
]
},
"Load Portfolio from Google Sheets": {
"main": [
[
{
"node": "Combine Portfolio Holdings",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
gmailOAuth2googleSheetsOAuth2ApiopenAiApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow receives a portfolio via webhook (uploaded CSV or Google Sheets), calculates allocation and concentration risk metrics, generates an OpenAI HTML risk report, emails it through Gmail, and returns a JSON API response with the report status. Receives a POST webhook…
Source: https://n8n.io/workflows/16443/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Automatically triage Product UAT feedback using AI, route it to the right tools and teams, and close the feedback loop with testers, all in one workflow.
This workflow accepts meeting transcripts via webhook (Zoom, Google Meet, Teams, Otter.ai, or manual notes), immediately processing them through an intelligent pipeline that eliminates post-meeting ad
This workflow turns scattered user feedback into a structured product backlog pipeline. It collects feedback from three channels (Telegram bot, Google Form/Sheets, and Gmail), normalizes it, and sends
This workflow automatically creates a weekly report of tasks. Every Friday morning, it collects all task details from Airtable, checks progress, and prepares a summary. It also highlights important or
cdp_router. Uses gmailTrigger, telegramTrigger, googleSheets, httpRequest. Event-driven trigger; 53 nodes.