This workflow corresponds to n8n.io template #14580 — we link there as the canonical source.
This workflow follows the Googlegemini → 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": "SpM1Pa120REd0LTN",
"name": "AI Institutional Stock Valuation Engine with Risk Scoring & Scenario Targets V12",
"tags": [
{
"id": "MrLFqPuujEL6vkPD",
"name": "Institutional-Grade Stoc",
"createdAt": "2026-02-24T19:56:14.685Z",
"updatedAt": "2026-02-24T19:56:14.685Z"
},
{
"id": "T6peYIB3ixkHZuR5",
"name": "Structured Targets. Quan",
"createdAt": "2026-02-24T19:56:06.627Z",
"updatedAt": "2026-02-24T19:56:06.627Z"
}
],
"nodes": [
{
"id": "c367abe2-4feb-4f01-839a-ba2427d86872",
"name": "loop_over_tickers",
"type": "n8n-nodes-base.splitInBatches",
"position": [
-1488,
1792
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "d49cb0cd-5482-42ac-a78d-c25592460947",
"name": "write_sentiment_to_sheets",
"type": "n8n-nodes-base.googleSheets",
"maxTries": 2,
"position": [
7584,
1344
],
"parameters": {
"columns": {
"value": {
"date": "={{ $json.date || '' }}",
"ma_50": "={{ $json.ma_50 || '' }}",
"model": "={{ $json.model || '' }}",
"stock": "={{ $json.stock || '' }}",
"ma_200": "={{ $json.ma_200 || '' }}",
"rsi_14": "={{ $json.rsi_14 || '' }}",
"f_score": "={{ $json.f_score || '' }}",
"gap_pct": "={{ $json.gap_pct ?? 0 }}",
"pt_base": "={{ $json.pt_base || '' }}",
"pt_bear": "={{ $json.pt_bear || '' }}",
"pt_bull": "={{ $json.pt_bull || '' }}",
"verdict": "={{ $json.verdict || $json.alert_verdict || '' }}",
"ma_signal": "={{ $json.ma_signal || '' }}",
"rationale": "={{ $json.rationale || '' }}",
"confidence": "={{ $json.confidence || '' }}",
"conviction": "={{ $json.conviction || '' }}",
"rsi_signal": "={{ $json.rsi_signal || '' }}",
"trend_tier": "={{ $json.trend_tier || '' }}",
"current_price": "={{ $json.current_price || '' }}",
"verdict_gemini": "={{ $json.verdict_gemini || '' }}",
"thesis_reversal": "={{ $json.thesis_reversal || false }}",
"verdict_chatgpt": "={{ $json.verdict_chatgpt || '' }}",
"previous_verdict": "={{ $json.previous_verdict?.[0] || '' }}",
"resolution_method": "={{ $json.resolution_method || '' }}",
"next_earnings_date": "={{ $json.next_earnings_date || '' }}"
},
"schema": [
{
"id": "stock",
"type": "string",
"display": true,
"required": false,
"displayName": "stock",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "date",
"type": "string",
"display": true,
"required": false,
"displayName": "date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "current_price",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "current_price",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "pt_bear",
"type": "string",
"display": true,
"required": false,
"displayName": "pt_bear",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "pt_base",
"type": "string",
"display": true,
"required": false,
"displayName": "pt_base",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "pt_bull",
"type": "string",
"display": true,
"required": false,
"displayName": "pt_bull",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "f_score",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "f_score",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "confidence",
"type": "string",
"display": true,
"required": false,
"displayName": "confidence",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "gap_pct",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "gap_pct",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "resolution_method",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "resolution_method",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "rationale",
"type": "string",
"display": true,
"required": false,
"displayName": "rationale",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "verdict",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "verdict",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "conviction",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "conviction",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "model",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "model",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "verdict_chatgpt",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "verdict_chatgpt",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "verdict_gemini",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "verdict_gemini",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "ma_50",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "ma_50",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "ma_signal",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "ma_signal",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "rsi_14",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "rsi_14",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "rsi_signal",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "rsi_signal",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "trend_tier",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "trend_tier",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "ma_200",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "ma_200",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "next_earnings_date",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "next_earnings_date",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "previous_verdict",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "previous_verdict",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "thesis_reversal",
"type": "string",
"display": true,
"removed": false,
"required": false,
"displayName": "thesis_reversal",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "defineBelow",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "gid=0",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1fbptcVE0mBjaIZJHkJzFBTdoVJVLgQOWmJXpCx40YyE/edit#gid=0",
"cachedResultName": "Sheet1"
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "1UwNknqr9d7W0Egq8ahlo9kb40B1BIGZEGdhUhCeM0Ck",
"cachedResultUrl": "https://docs.google.com/spreadsheets/d/1UwNknqr9d7W0Egq8ahlo9kb40B1BIGZEGdhUhCeM0Ck/edit?usp=drivesdk",
"cachedResultName": "Sentiments of my stocks for screener"
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 4.6
},
{
"id": "bba9fb51-a0e6-4023-aa87-6738ea40902c",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"position": [
-2832,
1792
],
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 7 * * 1-5"
}
]
}
},
"typeVersion": 1.2
},
{
"id": "d8e52ba2-be9a-4cc5-a858-ddfea568e406",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2896,
816
],
"parameters": {
"color": 7,
"width": 2080,
"height": 1488,
"content": "# 1. \ud83d\udce1 PHASE 1 \u2014 SCREENING & FILTERING\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\nRuns daily on schedule. Calls the FMP Company Screener \nto fetch up to 100 US-listed stocks filtered by:\n \u2022 Market Cap > $5B\n \u2022 Volume > 500K\n \u2022 Price > $10\n \u2022 Beta < 2.0\n \u2022 Actively trading, US only, NYSE/NASDAQ, no ETFs/funds\n\nScore_and_Prefilter then scores each stock on volume \nhealth, market cap sweet spot ($5B\u2013$100B), and beta \ngradient \u2014 with a sector diversity cap of max 4 stocks \nper sector \u2014 and outputs the top 20 candidates.\n\nThe 20 selected stocks are then passed one by one into \nthe analysis loop via \"Loop Over Items\".\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2699\ufe0f SETUP REQUIRED\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2022 FMP API Key \u2192 add to all HTTP Request nodes as query \n param: apikey = {{ your_key }}\n\u2022 FMP plan must support /stable/ endpoints and allow \n \u2265 300 calls/day (Starter plan or above)\n\u2022 Schedule Trigger \u2192 set your preferred daily run time\n (default: weekdays 7:00 AM)"
},
"typeVersion": 1
},
{
"id": "6de678ec-e030-490a-a8f1-8c222614d742",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-784,
-480
],
"parameters": {
"color": 7,
"width": 2744,
"height": 2252,
"content": "# 2. \ud83d\udcca PHASE 2A \u2014 FINANCIAL DATA COLLECTION\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\nFor each stock, the workflow fetches 13 FMP endpoints:\n \u2022 Income Statement (annual + TTM quarters)\n \u2022 Balance Sheet (annual + quarterly)\n \u2022 Cash Flow Statement (annual + quarterly)\n \u2022 Key Metrics & Ratios\n \u2022 Company Profile (price, sector, shares outstanding)\n \u2022 Sector P/E median\n\n\"Clean Read Financial\" computes and outputs:\n \u2022 EPS, BVPS, Revenue TTM, Gross/Operating Margins\n \u2022 FCF TTM, Net Income, Net Debt, Cash\n \u2022 Revenue Growth YoY\n \u2022 Piotroski F-Score (9-point scale)\n \u2022 Graham Number (intrinsic value estimate)\n \u2022 DCF Anchor (EV-based fair value per share)\n \u2022 Sector Median P/E for relative valuation\n\nAll values are null-safe. Stocks with missing critical \ndata still pass through with reduced confidence scores.\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2699\ufe0f SETUP REQUIRED\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2022 Same FMP API Key as Phase 1\n\u2022 No additional credentials needed for this phase\n\u2022 Verify all HTTP Request nodes point to /stable/ \n endpoints (NOT the deprecated /api/v3/)"
},
"typeVersion": 1
},
{
"id": "41563363-4ff3-4293-bad1-335940a352b7",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1984,
224
],
"parameters": {
"color": 7,
"width": 2308,
"height": 1552,
"content": "# 3. \ud83e\udd16 PHASE 3 \u2014 AI DUAL-MODEL ANALYSIS & TIEBREAKER\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\nROUND 1 \u2014 Parallel Analysis\n\nGPT-4o (ChatGPT): Institutional bull perspective\nGemini 2.5 Pro: Institutional bear perspective\n\nBoth models analyze full financial and news data, producing:\npt_bear, pt_base, pt_bull, confidence (0\u2013100), verdict (BUY/HOLD/SELL), and rationale.\n\nConsensus Check\n\nDo verdicts match?\nIs the price target gap < 25% of the current price?\nIf both are HOLD with confidence \u2265 65%, the consensus is accepted and the tiebreaker is skipped.\n\nTIEBREAKER \u2014 When Needed\n\nTriggered if verdicts differ or the gap exceeds 25%.\nChatGPT re-runs as a growth-focused bull.\nGemini re-runs as a risk-focused bear.\nFinal pt_base: Average of bull and bear targets.\nFinal verdict: Determined by upside percentage and the F-Score gate.\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2699\ufe0f SETUP REQUIRED\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nOpenAI API Key: Add to ChatGPT HTTP nodes\nAuthorization: Bearer {{ your_key }} (GPT-4o access required)\nGoogle Gemini API Key: Add to Gemini HTTP nodes\nkey={{ your_key }} (via Google AI Studio or Vertex AI)\nEstimated Cost: ~$0.43/day for 25 stocks\n(~$10\u2013$13/month across both models)"
},
"typeVersion": 1
},
{
"id": "816455d8-a4d5-4f38-8182-a1773ba2e07a",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
7504,
224
],
"parameters": {
"color": 7,
"width": 984,
"height": 1560,
"content": "# 5. \ud83d\udcbe PHASE 5 \u2014 STORE RESULTS & TELEGRAM SUMMARY\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\nAll analyzed stocks are stored in Google Sheets with key fields:\n\nstock, date, current_price, sector\npt_bear, pt_base, pt_bull\nverdict, confidence, f_score\nverdict_chatgpt, verdict_gemini\nprevious_verdict, previous_date (for backtesting)\nrationale summary\n\nAt the end of each daily run, a Telegram message summarizes:\n\nTotal stocks scanned\nBUY / HOLD / SELL counts\nTop BUY opportunities with upside and confidence\nAny STRONG BUY alerts from Phase 4\n\nA separate Signal Outcome Checker workflow reviews the sheet weekly to backtest past signals and sends a performance summary every Monday.\n\n\u2699\ufe0f SETUP REQUIRED\nGoogle Sheets\nCreate a sheet named Stock_Signals with these headers:\nstock | date | current_price | sector | pt_bear | pt_base | pt_bull | verdict | confidence | f_score | verdict_chatgpt | verdict_gemini | rationale | previous_verdict | previous_date\nIn n8n \u2192 Credentials, add Google Sheets OAuth2 and grant access.\nPaste the Sheet ID (from the URL between /d/ and /edit) into all Google Sheets nodes.\nTelegram\nCreate a bot via @BotFather using /newbot and copy the API Token.\nStart a chat with the bot (or add it to a group).\nRetrieve the Chat ID by visiting:\nhttps://api.telegram.org/bot<TOKEN>/getUpdates\nIn n8n \u2192 Credentials, add Telegram API and set the Chat ID in the Telegram node.\n\n\n\n\n\n\n"
},
"typeVersion": 1
},
{
"id": "00eed74a-c58c-452c-aef2-55cd38837814",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"position": [
2672,
1200
],
"parameters": {
"mode": "combine",
"options": {},
"joinMode": "keepEverything",
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "94ef6a9c-5d45-408e-ac48-e9dcd26a7278",
"name": "XML",
"type": "n8n-nodes-base.xml",
"position": [
576,
1952
],
"parameters": {
"options": {},
"dataPropertyName": "sa_xml"
},
"typeVersion": 1
},
{
"id": "8872eb3d-a788-4d47-bcd0-972d5181f31c",
"name": "Merge1",
"type": "n8n-nodes-base.merge",
"position": [
1792,
1120
],
"parameters": {
"mode": "combine",
"options": {},
"joinMode": "keepEverything",
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "c23cd5dc-fe35-40dc-b80b-6ce9a981dd9b",
"name": "If",
"type": "n8n-nodes-base.if",
"position": [
4592,
1296
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "8659bdf4-3891-4cf4-9187-ea800a3810f8",
"operator": {
"type": "boolean",
"operation": "false",
"singleValue": true
},
"leftValue": "={{$json.skip_row}}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "658cd539-7db6-48e9-9fb0-4829b290d974",
"name": "If1",
"type": "n8n-nodes-base.if",
"position": [
352,
2048
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "bf9993a5-39a5-4344-a1a6-5ba38e67e47c",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"leftValue": "sa_xml",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "27171373-95f9-4c28-9641-f0ea03188d4e",
"name": "Edit Fields2",
"type": "n8n-nodes-base.set",
"position": [
800,
1952
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "44ff26a3-5901-4e3b-9b15-9844a2d207b5",
"name": "stock",
"type": "string",
"value": "={{ $('Clean the news').item.json.stock }}"
},
{
"id": "fca99ba0-11fd-40a3-acf7-ea25579a653e",
"name": "rss",
"type": "object",
"value": "={{ $json.rss }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "d54ef15e-741d-4ec1-a782-3a37fbb4aa58",
"name": "Merge2",
"type": "n8n-nodes-base.merge",
"position": [
480,
528
],
"parameters": {
"mode": "combine",
"options": {},
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "c9fec1fe-afed-43b9-b504-ab5d1eed10b1",
"name": "Merge3",
"type": "n8n-nodes-base.merge",
"position": [
320,
400
],
"parameters": {
"mode": "combine",
"options": {},
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "b7fcd4c5-aba9-4197-993e-deba52f4d71c",
"name": "Merge4",
"type": "n8n-nodes-base.merge",
"position": [
656,
656
],
"parameters": {
"mode": "combine",
"options": {},
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "728d1fa8-3da4-4081-9cf9-bb2b14568d96",
"name": "Seekingalpha Articles",
"type": "n8n-nodes-base.httpRequest",
"onError": "continueRegularOutput",
"position": [
-96,
2048
],
"parameters": {
"url": "=https://seekingalpha.com/api/sa/combined/{{ $json.stock }}.xml",
"options": {
"response": {
"response": {
"responseFormat": "text",
"outputPropertyName": "sa_xml"
}
}
}
},
"typeVersion": 4.4
},
{
"id": "d68dfe96-4193-4b9f-b904-1ac6d51797cc",
"name": "Clean Profile",
"type": "n8n-nodes-base.code",
"position": [
112,
336
],
"parameters": {
"jsCode": "const profile = Array.isArray($json) ? $json[0] : $json;\n\nreturn {\n json: {\n stock: profile.symbol || $input.item.json.stock || \"UNKNOWN\",\n profile_data: profile\n }\n};\n"
},
"typeVersion": 2
},
{
"id": "cdf428ac-b822-4457-bae9-8bb1d14616d1",
"name": "Clean Income statement",
"type": "n8n-nodes-base.code",
"position": [
112,
480
],
"parameters": {
"jsCode": "const data = Array.isArray($json.data) ? $json.data : (Array.isArray($json) ? $json : [$json]);\nconst stock = (data[0] && data[0].symbol) || \"UNKNOWN\";\n\nreturn [{\n json: {\n stock: stock,\n income_quarterly: data\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "a9ad0d28-d7bd-41c1-aba4-12d670c4ae9c",
"name": "Clean Ccashflow",
"type": "n8n-nodes-base.code",
"position": [
112,
864
],
"parameters": {
"jsCode": "const data = Array.isArray($json.data) ? $json.data : (Array.isArray($json) ? $json : [$json]);\nconst stock = (data[0] && data[0].symbol) || \"UNKNOWN\";\n\nreturn [{\n json: {\n stock: stock,\n cashflow_quarterly: data\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "60674b73-2d6b-430e-9601-11d751368ef4",
"name": "Clean Read Financial",
"type": "n8n-nodes-base.code",
"position": [
1664,
720
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// \u2500\u2500 Guard: skip the loop's done-signal item \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction num(v) {\n if (v === null || v === undefined || v === '' || v === 'None') return null;\n const n = Number(String(v).replace(/[,$%]/g, ''));\n return isFinite(n) ? n : null;\n}\nfunction safeDiv(a, b) {\n return (a !== null && b !== null && b !== 0) ? a / b : null;\n}\n\nconst stock = $json.stock;\nconst profile = $json.profile_data || {};\nconst kmTTM = Array.isArray($json.key_metrics_ttm)\n ? ($json.key_metrics_ttm[0] || {})\n : ($json.key_metrics_ttm || {});\n\n// Use quarterly arrays; fall back gracefully when period/limit not yet added\nconst incQ = Array.isArray($json.income_quarterly) ? $json.income_quarterly : [];\nconst incA = Array.isArray($json.income_annual) ? $json.income_annual : [];\nconst balQ = Array.isArray($json.balance_quarterly) ? $json.balance_quarterly : [];\nconst balA = Array.isArray($json.balance_annual) ? $json.balance_annual : [];\nconst cfQ = Array.isArray($json.cashflow_quarterly) ? $json.cashflow_quarterly : [];\nconst cfA = Array.isArray($json.cashflow_annual) ? $json.cashflow_annual : [];\n\nconst current_price = num($json.current_price) || num(profile.price);\nconst sector = profile.sector || '';\n\n// For balance: prefer quarterly (debt/cash tracking), fall back to annual\nconst balSource = balQ.length ? balQ : balA;\n\n// \u2500\u2500 Quarterly arrays (last 4Q) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst revenue_last_4q = incQ.slice(0, 4).map(q => num(q.revenue));\n\nconst net_income_last_4q = incQ.slice(0, 4).map(q => num(q.netIncome));\n\nconst gross_margin_last_4q = incQ.slice(0, 4).map(q => {\n const rev = num(q.revenue), gp = num(q.grossProfit);\n return (rev && gp !== null && rev > 0) ? Number(((gp / rev) * 100).toFixed(2)) : null;\n});\n\nconst total_debt_last_4q = balSource.slice(0, 4).map(q => {\n const t = num(q.totalDebt);\n if (t !== null && t > 0) return t;\n const s = num(q.shortTermDebt) || 0, l = num(q.longTermDebt) || 0;\n return (s + l) > 0 ? (s + l) : null;\n});\n\nconst cash_last_4q = balSource.slice(0, 4).map(q => num(q.cashAndCashEquivalents));\n\n// \u2500\u2500 Latest balance \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst latestBal = balSource[0] || {};\nconst debt_latest = num(latestBal.totalDebt) ||\n ((num(latestBal.shortTermDebt) || 0) + (num(latestBal.longTermDebt) || 0)) || null;\nconst cash_latest = num(latestBal.cashAndCashEquivalents);\n\nlet net_debt_latest = null, net_cash_flag = null;\nif (debt_latest !== null && cash_latest !== null) {\n net_debt_latest = debt_latest - cash_latest;\n net_cash_flag = net_debt_latest < 0;\n}\n\n// \u2500\u2500 TTM: Revenue \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Prefer key_metrics_ttm; fall back to summing quarters\nlet revenue_ttm = null;\nif (num(kmTTM.revenuePerShareTTM) !== null && num(kmTTM.weightedAverageShsOutDilTTM) !== null) {\n revenue_ttm = num(kmTTM.revenuePerShareTTM) * num(kmTTM.weightedAverageShsOutDilTTM);\n} else {\n const r4 = revenue_last_4q.filter(v => v !== null);\n if (r4.length > 0) revenue_ttm = r4.reduce((a, b) => a + b, 0);\n}\n\n// \u2500\u2500 Revenue growth YoY (needs 8 quarters) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet revenue_growth_yoy = null;\nconst rev8 = incQ.slice(0, 8).map(q => num(q.revenue));\nconst curr_ttm = rev8.slice(0, 4).every(v => v !== null) ? rev8.slice(0, 4).reduce((a, b) => a + b, 0) : null;\nconst prev_ttm = rev8.slice(4, 8).every(v => v !== null) ? rev8.slice(4, 8).reduce((a, b) => a + b, 0) : null;\nif (curr_ttm !== null && prev_ttm !== null && prev_ttm !== 0) {\n revenue_growth_yoy = Number((((curr_ttm - prev_ttm) / prev_ttm) * 100).toFixed(2));\n}\n\n// \u2500\u2500 Net income TTM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst net_income_ttm = (net_income_last_4q.length === 4 && net_income_last_4q.every(v => v !== null))\n ? net_income_last_4q.reduce((a, b) => a + b, 0)\n : null;\n\n// \u2500\u2500 Gross margin TTM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet gross_margin_ttm = null;\nif (num(kmTTM.grossProfitMarginTTM) !== null) {\n // FMP returns this as decimal (0.36 = 36%)\n gross_margin_ttm = Number((num(kmTTM.grossProfitMarginTTM) * 100).toFixed(2));\n} else if (curr_ttm !== null && curr_ttm > 0) {\n const gp4 = incQ.slice(0, 4).map(q => num(q.grossProfit));\n if (gp4.every(v => v !== null)) {\n gross_margin_ttm = Number(((gp4.reduce((a, b) => a + b, 0) / curr_ttm) * 100).toFixed(2));\n }\n}\n\n// \u2500\u2500 Operating margin TTM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet operating_margin_ttm = null;\nif (num(kmTTM.operatingIncomeRatioTTM) !== null) {\n operating_margin_ttm = Number((num(kmTTM.operatingIncomeRatioTTM) * 100).toFixed(2));\n} else if (curr_ttm !== null && curr_ttm > 0) {\n const oi4 = incQ.slice(0, 4).map(q => num(q.operatingIncome));\n if (oi4.every(v => v !== null)) {\n operating_margin_ttm = Number(((oi4.reduce((a, b) => a + b, 0) / curr_ttm) * 100).toFixed(2));\n }\n}\n\n// \u2500\u2500 FCF TTM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet fcf_ttm = null;\nif (cfQ.length >= 4) {\n // Sum last 4 quarters: FMP provides freeCashFlow directly\n const fcf4 = cfQ.slice(0, 4).map(r => num(r.freeCashFlow));\n if (fcf4.every(v => v !== null)) {\n fcf_ttm = fcf4.reduce((a, b) => a + b, 0);\n } else {\n // Compute from OCF + capex (capex is negative in FMP)\n const ocf4 = cfQ.slice(0, 4).map(r => num(r.operatingCashFlow));\n const cap4 = cfQ.slice(0, 4).map(r => num(r.capitalExpenditure));\n if (ocf4.every(v => v !== null) && cap4.every(v => v !== null)) {\n fcf_ttm = ocf4.reduce((a, b) => a + b, 0) + cap4.reduce((a, b) => a + b, 0);\n }\n }\n} else if (cfQ.length > 0) {\n // Only 1 quarter available \u2014 use what we have as best estimate\n const r = cfQ[0];\n fcf_ttm = num(r.freeCashFlow) ??\n ((num(r.operatingCashFlow) !== null && num(r.capitalExpenditure) !== null)\n ? num(r.operatingCashFlow) + num(r.capitalExpenditure)\n : null);\n} else if (cfA.length) {\n const r = cfA[0];\n fcf_ttm = num(r.freeCashFlow) ??\n ((num(r.operatingCashFlow) !== null && num(r.capitalExpenditure) !== null)\n ? num(r.operatingCashFlow) + num(r.capitalExpenditure)\n : null);\n}\n\n// \u2500\u2500 Total debt TTM \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst total_debt_ttm = debt_latest;\n\n// \u2500\u2500 EPS, BVPS, Shares \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Prefer key_metrics_ttm; fall back to latest quarterly income\nconst eps_current = num(kmTTM.netIncomePerShareTTM)\n || (incQ.length ? num(incQ[0].epsDiluted) : null);\n\nconst bvps_current = num(kmTTM.bookValuePerShareTTM)\n || (() => {\n const eq = num(latestBal.totalStockholdersEquity);\n const sh = incQ.length ? num(incQ[0].weightedAverageShsOutDil) : null;\n return (eq !== null && sh !== null && sh > 0) ? Number((eq / sh).toFixed(2)) : null;\n })();\n\nconst shares_outstanding = num(kmTTM.weightedAverageShsOutDilTTM)\n || (incQ.length ? num(incQ[0].weightedAverageShsOutDil) : null);\n\n// \u2500\u2500 Piotroski F-Score (needs 2 annual years for meaningful score) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Uses annual income + annual balance + annual cashflow\nconst y0_inc = incA[0] || null, y1_inc = incA[1] || null;\nconst y0_bal = balA[0] || null, y1_bal = balA[1] || null;\nconst y0_cf = cfA[0] || null, y1_cf = cfA[1] || null;\n\nconst NI0 = y0_inc ? num(y0_inc.netIncome) : null;\nconst NI1 = y1_inc ? num(y1_inc.netIncome) : null;\nconst TA0 = y0_bal ? num(y0_bal.totalAssets) : null;\nconst TA1 = y1_bal ? num(y1_bal.totalAssets) : null;\nconst CFO0 = y0_cf ? num(y0_cf.operatingCashFlow) : null;\nconst CFO1 = y1_cf ? num(y1_cf.operatingCashFlow) : null;\nconst LTD0 = y0_bal ? num(y0_bal.longTermDebt) : null;\nconst LTD1 = y1_bal ? num(y1_bal.longTermDebt) : null;\nconst CA0 = y0_bal ? num(y0_bal.totalCurrentAssets) : null;\nconst CA1 = y1_bal ? num(y1_bal.totalCurrentAssets) : null;\nconst CL0 = y0_bal ? num(y0_bal.totalCurrentLiabilities) : null;\nconst CL1 = y1_bal ? num(y1_bal.totalCurrentLiabilities) : null;\nconst REV0 = y0_inc ? num(y0_inc.revenue) : null;\nconst REV1 = y1_inc ? num(y1_inc.revenue) : null;\nconst GP0 = y0_inc ? num(y0_inc.grossProfit) : null;\nconst GP1 = y1_inc ? num(y1_inc.grossProfit) : null;\nconst SH0 = y0_inc ? num(y0_inc.weightedAverageShsOutDil) : null;\nconst SH1 = y1_inc ? num(y1_inc.weightedAverageShsOutDil) : null;\n\n// Add this before the F-score block:\nconst hasY1 = !!(y1_inc && y1_bal && y1_cf);\n\n// Then wrap year-over-year comparisons:\nlet f_score = 0;\nif (NI0 !== null && NI0 > 0) f_score++; // F1\nif (CFO0 !== null && CFO0 > 0) f_score++; // F2\nif (hasY1 && safeDiv(NI0,TA0) !== null && safeDiv(NI0,TA0) > safeDiv(NI1,TA1)) f_score++; // F3\nif (CFO0 !== null && NI0 !== null && CFO0 > NI0) f_score++; // F4\nif (hasY1 && LTD0 !== null && LTD1 !== null && LTD0 < LTD1) f_score++; // F5\nif (hasY1 && safeDiv(CA0,CL0) !== null && safeDiv(CA0,CL0) > safeDiv(CA1,CL1)) f_score++; // F6\nif (hasY1 && SH0 !== null && SH1 !== null && SH0 <= SH1) f_score++; // F7\nif (hasY1 && safeDiv(GP0,REV0) !== null && safeDiv(GP0,REV0) > safeDiv(GP1,REV1)) f_score++; // F8\nif (hasY1 && safeDiv(REV0,TA0) !== null && safeDiv(REV0,TA0) > safeDiv(REV1,TA1)) f_score++; // F9\n\n\nconst f_score_data_ok = !!(y0_inc && y1_inc && y0_bal && y1_bal && y0_cf && y1_cf);\n\n// \u2500\u2500 Graham Number \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet graham_number = null;\nif (eps_current > 0 && bvps_current > 0) {\n graham_number = Number(Math.sqrt(22.5 * eps_current * bvps_current).toFixed(2));\n}\n\n// \u2500\u2500 DCF Anchor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst disc_rate_map = {\n 'Technology': 0.10, 'Financial Services': 0.09, 'Utilities': 0.08,\n 'Consumer Defensive': 0.09, 'Consumer Cyclical': 0.11,\n 'Healthcare': 0.10, 'Industrials': 0.10, 'Basic Materials': 0.10,\n 'Energy': 0.11, 'Communication Services': 0.10, 'Real Estate': 0.09\n};\nconst disc_rate = disc_rate_map[sector] || 0.10;\nconst terminal_growth = 0.03;\nlet dcf_anchor = null;\nif (fcf_ttm !== null && fcf_ttm > 0) {\n const fcf_growth = (curr_ttm !== null && prev_ttm !== null && prev_ttm > 0)\n ? Math.min((curr_ttm - prev_ttm) / prev_ttm, 0.30)\n : 0.08;\n const fcf5 = fcf_ttm * Math.pow(1 + fcf_growth, 5);\nconst dcf_ev = fcf5 / (disc_rate - terminal_growth);\ndcf_anchor = shares_outstanding > 0\n ? Number((dcf_ev / shares_outstanding).toFixed(2))\n : null;}\n\n// \u2500\u2500 Sector P/E \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst sectorPE = {\n 'Technology': 25, 'Financial Services': 12, 'Utilities': 15,\n 'Consumer Defensive': 19, 'Consumer Cyclical': 22,\n 'Healthcare': 20, 'Industrials': 18, 'Basic Materials': 16,\n 'Energy': 14, 'Communication Services': 21, 'Real Estate': 17\n};\nconst sector_median_pe = sectorPE[sector] || 18;\n\n// \u2500\u2500 Output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nreturn {\n json: {\n stock,\n eps_current,\n bvps_current,\n shares_outstanding,\n revenue_ttm,\n total_debt_ttm,\n gross_margin_ttm,\n operating_margin_ttm,\n fcf_ttm,\n net_income_ttm,\n revenue_last_4q: revenue_last_4q.some(v => v !== null) ? revenue_last_4q : [],\n net_income_last_4q: net_income_last_4q.some(v => v !== null) ? net_income_last_4q : [],\n gross_margin_last_4q: gross_margin_last_4q.some(v => v !== null) ? gross_margin_last_4q : null,\n total_debt_last_4q: total_debt_last_4q.some(v => v !== null) ? total_debt_last_4q : null,\n cash_last_4q: cash_last_4q.some(v => v !== null) ? cash_last_4q : null,\n revenue_growth_yoy,\n current_price,\n net_debt_latest,\n cash_latest,\n debt_latest,\n net_cash_flag,\n f_score,\n f_score_data_ok,\n sector,\n graham_number,\n dcf_anchor,\n sector_median_pe,\n cache: {\n status: 'fresh',\n source: 'fmp',\n last_updated: new Date().toISOString()\n }\n }\n};\n"
},
"typeVersion": 2
},
{
"id": "1cdf9419-1e47-4cf6-a0c0-fae5d413418b",
"name": "Clean Current Price",
"type": "n8n-nodes-base.code",
"position": [
96,
1168
],
"parameters": {
"jsCode": "const inJson = $json || {};\nconst q = Array.isArray(inJson) ? inJson[0] : inJson;\nconst stock = q.symbol || $input.item.json.stock || \"UNKNOWN\";\n\nreturn [{\n json: {\n stock: stock,\n current_price: q.price ? Number(q.price) : null\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "d468b9b4-ecd3-4f9e-bf6e-0ead530e8357",
"name": "Merge5",
"type": "n8n-nodes-base.merge",
"position": [
304,
960
],
"parameters": {
"mode": "combine",
"options": {},
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "5651b347-1f06-4321-9ca3-38c1df12ccc7",
"name": "Clean the news",
"type": "n8n-nodes-base.code",
"position": [
128,
2048
],
"parameters": {
"jsCode": "let saXml = null;\nlet stock = \"\";\n\ntry {\n stock =\n $json.stock ??\n $json.ticker ??\n ($node[\"loop_over_tickers\"]?.json?.stock ?? \"\");\n\n if (!$json.errorMessage && !$json.error) {\n\n saXml = // \u2190 no \"let\" here, assigns to outer variable\n (typeof $json.sa_xml === \"string\" && $json.sa_xml.trim()) ? $json.sa_xml.trim() :\n (typeof $json.body === \"string\" && $json.body.trim()) ? $json.body.trim() :\n (typeof $json.data === \"string\" && $json.data.trim()) ? $json.data.trim() :\n (typeof $json.responseBody === \"string\" && $json.responseBody.trim()) ? $json.responseBody.trim() :\n (typeof $json.text === \"string\" && $json.text.trim()) ? $json.text.trim() :\n null;\n\n if (!saXml && $binary) {\n const key = Object.keys($binary)[0];\n const b64 = key && $binary[key]?.data ? $binary[key].data : null;\n if (b64) {\n const decoded = Buffer.from(b64, \"base64\").toString(\"utf8\").trim();\n if (decoded) saXml = decoded;\n }\n }\n }\n} catch(e) {\n saXml = null;\n}\n\nreturn [{\n json: {\n stock,\n sa_xml: saXml,\n sa_ok: !!saXml\n }\n}];"
},
"typeVersion": 2
},
{
"id": "dabe6865-3a54-4f8f-93bf-e13a25925cb2",
"name": "return the news",
"type": "n8n-nodes-base.code",
"position": [
800,
2144
],
"parameters": {
"jsCode": "return [{\n json: {\n stock: $json.stock ?? \"\",\n seekingAlphaWindowHours: 96,\n seekingAlphaCount: 0,\n seekingAlphaHasRecent: false,\n seekingAlphaRecentCount: 0,\n seekingAlphaNews: [],\n seekingAlphaNewsText: null,\n seekingAlphaLatestDate: null,\n seekingAlphaError: $json.sa_error ?? \"No XML to parse\"\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "148c44a2-cae7-41b9-916d-95b1231078bb",
"name": "Final version of news",
"type": "n8n-nodes-base.code",
"position": [
1024,
2048
],
"parameters": {
"jsCode": "// ---- CONFIG ----\nconst windowHours = 96;\nconst fallbackCount = 3;\nconst now = Date.now();\nconst cutoff = now - windowHours * 3600 * 1000;\n\n// ---- GET INPUT ----\nconst input = $input.first().json;\n\nconst stock = input?.stock ?? input?.ticker ?? \"\";\n\n// ---- INPUT PATH ----\nconst items =\n input?.rss?.channel?.item ??\n input?.channel?.item ??\n input?.item ??\n [];\n\nconst arr = Array.isArray(items) ? items : [items];\n\n// ---- HELPERS ----\nfunction cleanHtml(s) {\n if (!s) return \"\";\n return String(s)\n .replace(/<script[\\s\\S]*?<\\/script>/gi, \"\")\n .replace(/<style[\\s\\S]*?<\\/style>/gi, \"\")\n .replace(/<\\/?[^>]+>/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n}\n\nfunction toMillis(dateStr) {\n const t = Date.parse(dateStr);\n return Number.isFinite(t) ? t : NaN;\n}\n\nfunction getLink(i) {\n const l = i.link;\n if (!l) return \"\";\n if (typeof l === \"string\") return l;\n if (Array.isArray(l)) return getLink({ link: l[0] });\n return l[\"#text\"] || l.href || l?.$?.href || \"\";\n}\n\n// ---- PARSE ALL ITEMS ----\nconst parsedAll = arr\n .map(i => {\n const publishedAt = i.pubDate || i.published || i.date || \"\";\n const t = toMillis(publishedAt);\n return {\n source: \"SeekingAlpha_RSS\",\n title: cleanHtml(i.title),\n link: getLink(i),\n publishedAt,\n publishedAtMs: t,\n snippet: cleanHtml(i.description || i.summary || i[\"content:encoded\"])\n };\n })\n .filter(n => n.title && n.link && Number.isFinite(n.publishedAtMs))\n .sort((a, b) => b.publishedAtMs - a.publishedAtMs);\n\n// ---- DEDUPE ----\nconst seen = new Set();\nconst dedupedAll = [];\nfor (const n of parsedAll) {\n if (seen.has(n.link)) continue;\n seen.add(n.link);\n dedupedAll.push(n);\n}\n\n// ---- RECENT WINDOW ----\nconst recent = dedupedAll.filter(n => n.publishedAtMs >= cutoff);\n\n// ---- FINAL SELECTION ----\nconst selected = recent.length > 0 ? recent : dedupedAll.slice(0, fallbackCount);\nconst hasRecent = recent.length > 0;\n\n// ---- CREATE COMPACT TEXT ----\nconst seekingAlphaNewsText = selected.length > 0\n ? selected.map(n => {\n const shortSnippet = n.snippet.length > 240\n ? n.snippet.slice(0, 240) + \"\u2026\"\n : n.snippet;\n return `- ${n.publishedAt} | ${n.title}\\n ${n.link}\\n Snippet: ${shortSnippet}`;\n }).join(\"\\n\\n\")\n : null;\n\nreturn [{\n json: {\n stock,\n seekingAlphaWindowHours: windowHours,\n seekingAlphaHasRecent: hasRecent,\n seekingAlphaRecentCount: recent.length,\n seekingAlphaCount: selected.length,\n seekingAlphaNews: selected.map(({ publishedAtMs, ...rest }) => rest),\n seekingAlphaNewsText,\n seekingAlphaLatestDate: dedupedAll[0]?.publishedAt ?? null\n }\n}];"
},
"typeVersion": 2
},
{
"id": "35e3f1cb-39ed-4ee8-ba67-c07b544c8a31",
"name": "Clean Up ChatGPT",
"type": "n8n-nodes-base.code",
"position": [
2448,
1072
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "let parsed = $json;\nconst t = $json?.output?.[0]?.content?.[0]?.text;\nif (t) {\n if (typeof t === \"string\") {\n try { parsed = JSON.parse(t); } catch(e) { parsed = $json; }\n } else if (typeof t === \"object\") {\n parsed = t;\n }\n}\n\nconst stock = parsed?.stock || $json?.stock || \"UNKNOWN\";\nconst source = $('Merge1').first().json;\n// Carry all financial fields forward from the original input\nconst financials = {\n current_price: source?.current_price ?? null,\n eps_current: source?.eps_current ?? null,\n bvps_current: source?.bvps_current ?? null,\n revenue_last_4q: source?.revenue_last_4q ?? null,\n net_income_last_4q: source?.net_income_last_4q ?? null,\n gross_margin_last_4q: source?.gross_margin_last_4q ?? null,\n total_debt_last_4q: source?.total_debt_last_4q ?? null,\n net_debt_latest: source?.net_debt_latest ?? null,\n net_cash_flag: source?.net_cash_flag ?? null,\n operating_margin_ttm: source?.operating_margin_ttm ?? null,\n fcf_ttm: source?.fcf_ttm ?? null,\n revenue_growth_yoy: source?.revenue_growth_yoy ?? null,\n f_score_data_ok: source?.f_score_data_ok ?? null,\n sector: source?.sector ?? null,\n seekingAlphaNewsText: source?.seekingAlphaNewsText ?? null,\n};\n\nreturn {\n json: {\n chatgpt_result: parsed,\n stock,\n ...financials\n }\n};"
},
"typeVersion": 2
},
{
"id": "fb32f4fd-27f3-4191-b6b9-aaf31dc1f5ab",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
-768,
1792
],
"parameters": {
"color": 7,
"width": 2728,
"height": 732,
"content": "# 2. \ud83d\udcf0 PHASE 2B \u2014 NEWS & SENTIMENT SCRAPING\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\nFor each stock, the workflow fetches the latest news \nheadlines and sentiment signals from Seeking Alpha via \nthe FMP /stable/news/stock endpoint.\n\nThe top recent articles are extracted and passed as \na news context block into the AI prompt, allowing the \nmodel to factor in:\n \u2022 Earnings surprises / guidance changes\n \u2022 Analyst upgrades or downgrades\n \u2022 Macro or sector headwinds\n \u2022 Legal, regulatory, or ESG risks\n\nNews sentiment feeds directly into the AI confidence \nscoring:\n +10 if POSITIVE sentiment detected\n \u221215 if NEGATIVE sentiment detected\n\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2699\ufe0f SETUP REQUIRED\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\u2022 No Seeking Alpha account needed \u2014 data is sourced \n through the FMP news aggregation layer"
},
"typeVersion": 1
},
{
"id": "3b0140cf-887b-48c4-a8d2-e6fcfd49cb7b",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"position": [
7968,
1536
],
"parameters": {
"amount": 30
},
"typeVersion": 1.1
},
{
"id": "f929fe77-17c6-4401-a1bc-1af59423e909",
"name": "Send a text message",
"type": "n8n-nodes-base.telegram",
"position": [
-1152,
1152
],
"parameters": {
"text": "={{ $json.summary_message }}",
"chatId": "123456789",
"additionalFields": {}
},
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.2
},
{
"id": "19faf08a-4096-4564-a8b4-c4d5411016db",
"name": "Merge6",
"type": "n8n-nodes-base.merge",
"position": [
3920,
1168
],
"parameters": {
"mode": "combine",
"options": {},
"joinMode": "keepEverything",
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "ea4b2dc7-568f-45e0-a6bc-89f37b28c5e4",
"name": "Clean up Gemini",
"type": "n8n-nodes-base.code",
"position": [
2448,
1536
],
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "let parsed = $json;\nif ($json.output && typeof $json.output === \"string\") {\n try {\n const cleaned = $json.output.replace(/```json\\n?|```/g, \"\").trim();\n parsed = JSON.parse(cleaned);\n } catch(e) { parsed = $json; }\n} else if ($json.content?.parts?.[0]?.text) {\n try {\n parsed = JSON.parse($json.content.parts[0].text);\n } catch(e) { parsed = $json; }\n}\nif (parsed && parsed.gemini_result) parsed = parsed.gemini_result;\n\nconst stock = parsed?.stock || $json?.stock || \"UNKNOWN\";\nconst source = $('Merge1').first().json;\n// Carry all financial fields forward from the original input\nconst financials = {\n current_price: source?.current_price ?? null,\n eps_current: source?.eps_current ?? null,\n bvps_current: source?.bvps_current ?? null,\n revenue_last_4q: source?.revenue_last_4q ?? null,\n net_income_last_4q: source?.net_income_last_4q ?? null,\n gross_margin_last_4q: source?.gross_margin_last_4q ?? null,\n total_debt_last_4q: source?.total_debt_last_4q ?? null,\n net_debt_latest: source?.net_debt_latest ?? null,\n net_cash_flag: source?.net_cash_flag ?? null,\n operating_margin_ttm: source?.operating_margin_ttm ?? null,\n fcf_ttm: source?.fcf_ttm ?? null,\n revenue_growth_yoy: source?.revenue_growth_yoy ?? null,\n f_score_data_ok: source?.f_score_data_ok ?? null,\n sector: source?.sector ?? null,\n seekingAlphaNewsText: source?.seekingAlphaNewsText ?? null,\n};\n\nreturn {\n json: {\n gemini_result: parsed,\n stock,\n ...financials\n }\n};"
},
"typeVersion": 2
},
{
"id": "9fdf4d46-3b28-4530-80ca-b213e8f67772",
"name": "Merge7",
"type": "n8n-nodes-base.merge",
"position": [
4368,
1296
],
"parameters": {
"mode": "combine",
"options": {},
"joinMode": "keepEverything",
"fieldsToMatchString": "stock"
},
"typeVersion": 3.2
},
{
"id": "f2bc794c-1313-4b41-bee2-a9c87ae6fcf2",
"name": "Tide Breaker - Bull",
"type": "@n8n/n8n-nodes-langchain.openAi",
"maxTries": 2,
"position": [
3344,
976
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "gpt-4o",
"cachedResultName": "GPT-4O"
},
"options": {
"textFormat": {
"textOptions": {
"type": "json_object"
}
}
},
"responses": {
"values": [
{
"content": "=YOUR ROLE: You are a GROWTH-FOCUSED bull analyst.\nThis is a TIEBREAKER \u2014 two models disagreed on this stock.\nMake the strongest BUY case the data can justify.\n- Select the higher multiple when between tiers\n- Prefer 15% discount when fundamentals are arguable \n- Classify ambiguous or mixed news as NEUTRAL unless there is a clear, evidence-backed positive catalyst\n- Your pt_base = maximum justifiable OPPORTUNITY value\n- Never fabricate data. Stay within input bounds only.\n----\nYou are a Senior Equity Analyst with 50+ years of institutional market experience.\nYou receive structured financial data, news context, and a Quality F-Score and must produce disciplined valuation targets and a risk-adjusted verdict.\nYou must think like an institutional portfolio manager: conservative, risk-first, evidence-based.\n\nINPUT FIELDS AVAILABLE:\n\nSTRUCTURED DATA INPUT (REAL VALUES \u2014 USE THESE ONLY)\n{\n\"stock\": \"{{ $json.stock }}\",\n\"current_price\": {{ $json.current_price }},\n\"eps_current\": {{ $json.eps_current }},\n\"bvps_current\": {{ $json.bvps_current }},\n\"revenue_last_4q\": {{ $json.revenue_last_4q }},\n\"revenue_ttm\":{{ $json.revenue_ttm }},\n\"net_income_last_4q\": {{ $json.net_income_last_4q }},\n\"gross_margin_last_4q\": {{ $json.gross_margin_last_4q }},\n\"total_debt_last_4q\": {{ $json.total_debt_last_4q }},\n\"net_debt_latest\": {{ $json.net_debt_latest }},\n\"net_cash_flag\": {{ $json.net_cash_flag }},\n\"operating_margin_ttm\": {{ $json.operating_margin_ttm }},\n\"fcf_ttm
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.
googlePalmApigoogleSheetsOAuth2ApiopenAiApitelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
This workflow automatically analyses daily stock data and produces buy, hold or sell signals by combining financial metrics from Financial Modelling Prep with consensus from both GPT-4o and Gemini. It writes the resulting recommendations to Google Sheets and sends concise alerts via Telegram, freeing investors and portfolio managers from manual research while maintaining a consistent daily process. The core step is a dual-model review that cross-checks AI outputs against quantitative indicators before any signal is finalised.
Use it when you need repeatable, rules-based signals for a watchlist of stocks rather than one-off research. Avoid it for intraday trading or when regulatory approval is required, as the system runs on daily data only. Common variations include swapping the AI models, adding sector filters, or expanding the output sheet columns.
About this workflow
Overview This is a production-grade, fully automated stock analysis system built entirely in n8n. It combines institutional-level financial analysis, dual AI model consensus, and a self-improving backtesting loop — all running on autopilot, every single day.
Source: https://n8n.io/workflows/14580/ — 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.
AI Institutional Stock Valuation Engine with Risk Scoring & Scenario Targets
A professional AI equity analysis automation built on n8n that transforms structured financial data and real-time news into disciplined, risk-adjusted price targets and actionable BUY/HOLD/SELL signal
Takes a product image from Google Sheets, adds frozen effect with Gemini, generates ASMR video with Veo3, writes captions with GPT-4o, and posts to 4 platforms automatically. Schedule trigger picks fi
Automatically captures a screenshot of a tech news homepage, extracts headlines into structured JSON, logs them in Google Sheets, and posts a daily trend report (7–10 bullet points) to Telegram at 07:
Beydigital Media – Lead Generation & AI Email Automation. Uses httpRequest, openAi, gmail, googleSheets. Scheduled trigger; 18 nodes.