{
  "name": "EP Ratio Screener (sanitized template)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 11,
              "triggerAtMinute": 1
            }
          ]
        }
      },
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.3,
      "position": [
        16736,
        11888
      ],
      "id": "10b8f2d4-ddf4-478a-8cf2-bc4d4693846e",
      "name": "Schedule Trigger"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ep-ratio-screener",
        "responseMode": "responseNode",
        "options": {}
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        16736,
        11536
      ],
      "id": "9b0fd086-ba52-4748-a190-7a7fe54bf891",
      "name": "Webhook"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "import pandas as pd\nfrom pandas.tseries.holiday import USFederalHolidayCalendar\n\ntoday = pd.Timestamp.today().normalize()\ncal = USFederalHolidayCalendar()\nholidays = cal.holidays(start=today.replace(month=1, day=1), end=today.replace(month=12, day=31))\nis_weekend = today.dayofweek >= 5\nis_holiday = today in holidays\nreturn {\"is_working_day\": not (is_weekend or is_holiday)}"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        16928,
        11888
      ],
      "id": "c5260e82-15eb-4ae4-9cb3-4baf78e94bcd",
      "name": "Code in Python"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "fb91d733-aa5e-4374-b717-a5239b402d99",
              "leftValue": "={{ $json.is_working_day }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        17120,
        11888
      ],
      "id": "c40c1c5e-a75e-4668-9ef2-130d0746a409",
      "name": "If"
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "import json\n\nitem = _items[0]\njson_data = item.get('json', item)\nbody = json_data.get('body', {}) or {}\n\nif isinstance(body, str):\n    try:\n        body = json.loads(body)\n    except Exception:\n        body = {}\n\ntickers = body.get('tickers', None)\nuse_fmp_screen = body.get('use_fmp_screen', None)\n\nif tickers is None:\n    return [{'json': {'route': 'error', 'reason': 'tickers is required when calling via webhook, e.g. [\"AAPL\", \"MSFT\"]'}}]\n\nif not isinstance(tickers, list) or len(tickers) == 0:\n    return [{'json': {'route': 'error', 'reason': 'tickers must be a non-empty list, e.g. [\"AAPL\", \"MSFT\"]'}}]\n\nreturn [{'json': {\n    'route': 'execute',\n    'tickers': tickers,\n    'use_fmp_screen': bool(use_fmp_screen) if use_fmp_screen is not None else False\n}}]"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        16928,
        11536
      ],
      "id": "d0823572-246c-4b47-876e-62a92dcf9f0f",
      "name": "ExtractWebhookBody"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "ep-wv-cond-01",
              "leftValue": "={{ $json.route }}",
              "rightValue": "execute",
              "operator": {
                "type": "string",
                "operation": "equals",
                "name": "filter.operator.equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        17120,
        11536
      ],
      "id": "45c94122-46d7-476e-8808-ca35d8ae59c6",
      "name": "Webhook Validation"
    },
    {
      "parameters": {
        "jsCode": "const reason = $input.first().json.reason || 'Invalid request';\n\nreturn [{ json: {\n  status: 'error',\n  reason: reason,\n  schema: {\n    description: 'POST a JSON body to run EP Ratio screening. tickers is required for webhook requests; the daily scheduled job uses its own built-in default ticker list.',\n    fields: {\n      tickers:        { type: 'array<string>', required: true, example: ['AAPL','MSFT','NVDA'], note: 'Required. Must be a non-empty array, not a string. Not defaulted — the built-in ticker list is only used by the daily scheduled run.' },\n      use_fmp_screen: { type: 'boolean', default: false, example: true, note: 'Use FMP pre-filter before EP ratio scoring.' }\n    },\n    validation_rules: [\n      'tickers is required and must be a non-empty array — e.g. [\"AAPL\",\"MSFT\"], not \"AAPL\"'\n    ],\n    example_requests: {\n      custom_tickers: { tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'] },\n      with_fmp:       { tickers: ['AAPL', 'MSFT', 'NVDA'], use_fmp_screen: true }\n    },\n    curl_example: \"curl -X POST https://n8n.limex.pro/webhook/ep-ratio-screener -H 'Content-Type: application/json' -d '{\\\"tickers\\\":[\\\"AAPL\\\",\\\"MSFT\\\",\\\"NVDA\\\"]}'\"\n  }\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        17312,
        11392
      ],
      "id": "a25e16e7-ba37-4976-8216-a93f6c1fc67d",
      "name": "Build Error Response"
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify($json) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        17520,
        11392
      ],
      "id": "a0bd0179-e4d2-47bb-92e5-1e461881164a",
      "name": "Respond Error",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        17328,
        11888
      ],
      "id": "9230efff-7543-4f99-b86f-90d15d71840f",
      "name": "Merge Triggers"
    },
    {
      "parameters": {
        "jsCode": "const j = $input.first().json;\nconst defaultTickers = [\"AAPL\",\"MSFT\",\"GOOG\",\"CSCO\",\"AMZN\",\"JPM\",\"BAC\",\"GS\",\"JNJ\",\"UNH\",\"XOM\",\"CVX\",\"PG\",\"KO\",\"WMT\",\"HD\",\"V\",\"MA\",\"LLY\",\"NVDA\"];\nreturn [{ json: {\n  tickers:        j.tickers        || defaultTickers,\n  use_fmp_screen: j.use_fmp_screen != null ? j.use_fmp_screen : false,\n  trigger_source: j.route === 'execute' ? 'webhook' : 'schedule'\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        17536,
        11888
      ],
      "id": "a9e3c929-0f11-4e75-b85f-e49811bda03e",
      "name": "Set Parameters"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "<mcp-endpoint-url>",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Accept",
              "value": "application/json, text/event-stream"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\n  \"jsonrpc\": \"2.0\", \"id\": 1,\n  \"method\": \"initialize\",\n  \"params\": {\n    \"protocolVersion\": \"2024-11-05\",\n    \"capabilities\": {},\n    \"clientInfo\": { \"name\": \"n8n\", \"version\": \"1.0\" }\n  }\n}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        17712,
        11888
      ],
      "id": "fd0c6a54-e591-4978-9c42-09ae4c7ff26d",
      "name": "GetSessionID"
    },
    {
      "parameters": {
        "jsCode": "const sessionId = $input.first().json.headers['mcp-session-id']\nreturn [{ json: { sessionId } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        17872,
        11888
      ],
      "id": "1e623e44-5826-45e7-b559-d5ef73f7e2ce",
      "name": "ExtractSessionID"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "<mcp-endpoint-url>",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Mcp-Session-Id",
              "value": "={{ $('ExtractSessionID').item.json.sessionId }}"
            },
            {
              "name": "Accept",
              "value": "application/json, text/event-stream"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\n  \"jsonrpc\": \"2.0\", \"id\": 2,\n  \"method\": \"tools/call\",\n  \"params\": {\n    \"name\": \"load_skills\",\n    \"arguments\": { \"request\": {} }\n  }\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        18048,
        11888
      ],
      "id": "d5152e8c-d271-4d32-8e99-fe35993f70d6",
      "name": "LoadSkills"
    },
    {
      "parameters": {
        "jsCode": "const p = $('Set Parameters').first().json;\nconst tickers = p.tickers;\nconst BATCH_SIZE = 20;\nconst items = [];\nfor (let i = 0; i < tickers.length; i += BATCH_SIZE) {\n  const batch = tickers.slice(i, i + BATCH_SIZE);\n  items.push({ json: {\n    body: JSON.stringify({\n      jsonrpc: '2.0',\n      id: 3,\n      method: 'tools/call',\n      params: {\n        name: 'screen-tickers',\n        arguments: {\n          request: {\n            tickers: batch,\n            use_fmp_screen: p.use_fmp_screen\n          }\n        }\n      }\n    })\n  }});\n}\nreturn items;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        18240,
        11888
      ],
      "id": "b064d74e-bb88-4a13-93ca-4e25cbbf3967",
      "name": "PrepareScreenBody"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "<mcp-endpoint-url>",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Mcp-Session-Id",
              "value": "={{ $('ExtractSessionID').item.json.sessionId }}"
            },
            {
              "name": "Accept",
              "value": "application/json, text/event-stream"
            }
          ]
        },
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ $json.body }}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        18400,
        11888
      ],
      "id": "13db54c9-2101-47de-b741-4b998d9f14b8",
      "name": "ScreenTickers"
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.data;\nconst match = raw.match(/event: message\\r?\\ndata: (.+?)(?:\\r?\\n|$)/);\nif (!match) throw new Error('No SSE message event found');\nconst parsed = JSON.parse(match[1]);\nlet meta;\nif (parsed.result && parsed.result.structuredContent) {\n  meta = parsed.result.structuredContent.result;\n} else if (parsed.result && Array.isArray(parsed.result.content) && parsed.result.content[0]) {\n  const text = parsed.result.content[0].text;\n  try { meta = JSON.parse(text); } catch(e) { throw new Error('SSE content not JSON: ' + String(text).slice(0, 200)); }\n} else {\n  throw new Error('Unexpected SSE structure. Keys: ' + JSON.stringify(parsed.result ? Object.keys(parsed.result) : 'no result'));\n}\nif (!meta || !Array.isArray(meta.results)) {\n  throw new Error('Batch stored server-side (too large). Reduce BATCH_SIZE. Keys: ' + JSON.stringify(meta ? Object.keys(meta) : String(meta)));\n}\nreturn [{ json: meta }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        18592,
        11888
      ],
      "id": "7924c984-efb5-44b9-acfd-a7056f02755c",
      "name": "ParseScreenSSE"
    },
    {
      "parameters": {
        "jsCode": "const items = $input.all();\nconst allResults = [];\nlet riskFree = null;\nfor (const item of items) {\n  const meta = item.json;\n  if (Array.isArray(meta.results)) allResults.push(...meta.results);\n  if (riskFree === null && meta.risk_free_rate_pct != null) riskFree = meta.risk_free_rate_pct;\n}\nreturn [{ json: {\n  meta: {\n    results: allResults,\n    count: allResults.length,\n    risk_free_rate_pct: riskFree\n  }\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        18768,
        11888
      ],
      "id": "aa8ef1ec-74fc-4a30-9f42-66c3f621e9a2",
      "name": "CombineResults"
    },
    {
      "parameters": {
        "jsCode": "const meta = $json.meta;\nif (!meta || !Array.isArray(meta.results)) {\n  throw new Error('meta.results missing. Keys: ' + JSON.stringify(meta ? Object.keys(meta) : String(meta)));\n}\n\nconst tickers = meta.results;\n\n\n// ── Signal logic ──────────────────────────────────────────────\nfunction getSignal(t) {\n  const checks = ['✓ yield','✓ balance','✓ roic','✓ consistency','✓ spread'];\n  const score  = checks.filter(k => t[k] === '✓').length;\n  const spread = t.yield_spread_pct ?? 0;\n\n  if (score >= 5)                    return { label: '🟢 LONG',            color: '#1a7a1a', bg: '#eaffea' };\n  if (score === 4)                   return { label: '🟡 WATCH',           color: '#7a6a00', bg: '#fffbe6' };\n  if (score <= 3 && spread < 0)      return { label: '🔴 AVOID',   color: '#c0392b', bg: '#fff0f0' };\n  return                                    { label: '🟠 NEUTRAL',          color: '#a04000', bg: '#fff5ee' };\n}\n\n// ── Columns ───────────────────────────────────────────────────\nconst columns = [\n  { key: 'ticker',              label: 'Ticker' },\n  { key: '_signal',             label: 'Signal' },\n  { key: 'price',               label: 'Price ($)' },\n  { key: 'market_cap_B',        label: 'Mkt Cap ($B)' },\n  { key: 'norm_earnings_yield', label: 'Norm E/Y (%)' },\n  { key: 'yield_spread_pct',    label: 'Yield Spread (%)' },\n  { key: 'roic_pct',            label: 'ROIC (%)' },\n  { key: 'debt_to_equity',      label: 'D/E' },\n  { key: 'interest_coverage',   label: 'Int. Coverage' },\n  { key: 'profitable_years',    label: 'Profitable Yrs' },\n  { key: '✓ yield',             label: 'Yield ✓' },\n  { key: '✓ balance',           label: 'Balance ✓' },\n  { key: '✓ roic',              label: 'ROIC ✓' },\n  { key: '✓ consistency',       label: 'Consistency ✓' },\n  { key: '✓ spread',            label: 'Spread ✓' },\n];\n\nconst headerRow = columns\n  .map(c => `<th style=\"background:#1a1a2e;color:white;padding:8px 12px;text-align:left;font-size:12px\">${c.label}</th>`)\n  .join('');\n\nconst dataRows = tickers.map((t, i) => {\n  const sig = getSignal(t);\n  const bg  = sig.bg;\n\n  const cells = columns.map(c => {\n    let val = t[c.key];\n\n    if (c.key === '_signal') {\n      val = `<span style=\"color:${sig.color};font-weight:bold;white-space:nowrap\">${sig.label}</span>`;\n    } else if (c.key === 'yield_spread_pct') {\n      const color = (val ?? 0) >= 0 ? '#1a7a1a' : '#c0392b';\n      val = val !== null ? `<span style=\"color:${color}\">${val}%</span>` : '<span style=\"color:#aaa\">—</span>';\n    } else if (val === null || val === undefined) {\n      val = '<span style=\"color:#aaa\">—</span>';\n    }\n    return `<td style=\"padding:7px 12px;font-size:12px;border-bottom:1px solid #eee\">${val}</td>`;\n  }).join('');\n\n  return `<tr style=\"background:${bg}\">${cells}</tr>`;\n}).join('');\n\nconst html = `\n<html><body style=\"font-family:Arial,sans-serif;max-width:960px;margin:auto;padding:20px\">\n  <h2 style=\"color:#1a1a2e\">EP Ratio Screen — Long/Short Signal</h2>\n  <p style=\"color:#555;font-size:13px\">\n    Screened: ${meta.count} tickers &nbsp;|&nbsp;\n    Long: ${tickers.filter(t => getSignal(t).label.includes('LONG')).length} &nbsp;|&nbsp;\n    Short/Avoid: ${tickers.filter(t => getSignal(t).label.includes('SHORT')).length} &nbsp;|&nbsp;\n    Date: ${new Date().toISOString().split('T')[0]} &nbsp;|&nbsp;\n    Risk-free: ${meta.risk_free_rate_pct}%\n  </p>\n  <table style=\"width:100%;border-collapse:collapse;font-family:Arial,sans-serif\">\n    <thead><tr>${headerRow}</tr></thead>\n    <tbody>${dataRows}</tbody>\n  </table>\n  <p style=\"color:#aaa;font-size:11px;margin-top:16px\">\n    Signal: 🟢 LONG = all 5 checks pass &nbsp;|&nbsp; 🟡 WATCH = 4 checks &nbsp;|&nbsp;\n    🔴 AVOID = ≤3 checks + negative yield spread &nbsp;|&nbsp; 🟠 NEUTRAL = ≤2 checks\n  </p>\n</body></html>`;\n\nconst tickerResults = tickers.map(t => ({\n  ticker:             t.ticker,\n  signal:             getSignal(t).label,\n  price:              t.price,\n  norm_earnings_yield: t.norm_earnings_yield,\n  yield_spread_pct:   t.yield_spread_pct,\n  roic_pct:           t.roic_pct\n}));\n\nreturn [{ json: {\n  html,\n  subject: `EP Ratio Signal — ${new Date().toISOString().split('T')[0]}`,\n  ticker_results: tickerResults,\n  screened_count: meta.count,\n  risk_free_rate_pct: meta.risk_free_rate_pct,\n  analysis_date: new Date().toISOString().split('T')[0]\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        18048,
        11392
      ],
      "id": "fc54d21d-8bc1-4e66-86dc-6e3efeb63d9b",
      "name": "Code in JavaScript"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "ep-route-trig-cond",
              "leftValue": "={{ $('Set Parameters').first().json.trigger_source }}",
              "rightValue": "webhook",
              "operator": {
                "type": "string",
                "operation": "equals",
                "name": "filter.operator.equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        18224,
        11392
      ],
      "id": "c8321d73-01ab-4be1-adc7-3f237d734a72",
      "name": "Route by Trigger"
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ JSON.stringify({ status: 'success', analysis_date: $json.analysis_date, screened_count: $json.screened_count, risk_free_rate_pct: $json.risk_free_rate_pct, ticker_results: $json.ticker_results }) }}",
        "options": {
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.5,
      "position": [
        18448,
        11232
      ],
      "id": "3de9231b-9d4f-48f6-b4d3-43677c99bd60",
      "name": "Respond to Webhook Success"
    }
  ],
  "connections": {
    "Schedule Trigger": {
      "main": [
        [
          {
            "node": "Code in Python",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code in Python": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If": {
      "main": [
        [
          {
            "node": "Merge Triggers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "ExtractWebhookBody",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ExtractWebhookBody": {
      "main": [
        [
          {
            "node": "Webhook Validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Validation": {
      "main": [
        [
          {
            "node": "Merge Triggers",
            "type": "main",
            "index": 1
          }
        ],
        [
          {
            "node": "Build Error Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Error Response": {
      "main": [
        [
          {
            "node": "Respond Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Triggers": {
      "main": [
        [
          {
            "node": "Set Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Parameters": {
      "main": [
        [
          {
            "node": "GetSessionID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GetSessionID": {
      "main": [
        [
          {
            "node": "ExtractSessionID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ExtractSessionID": {
      "main": [
        [
          {
            "node": "LoadSkills",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LoadSkills": {
      "main": [
        [
          {
            "node": "PrepareScreenBody",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "PrepareScreenBody": {
      "main": [
        [
          {
            "node": "ScreenTickers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ScreenTickers": {
      "main": [
        [
          {
            "node": "ParseScreenSSE",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "ParseScreenSSE": {
      "main": [
        [
          {
            "node": "CombineResults",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CombineResults": {
      "main": [
        [
          {
            "node": "Code in JavaScript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code in JavaScript": {
      "main": [
        [
          {
            "node": "Route by Trigger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Trigger": {
      "main": [
        [
          {
            "node": "Respond to Webhook Success",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "availableInMCP": false
  },
  "nodeGroups": []
}
