{
  "name": "Factor Loading Report (sanitized template)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ff-factor-loading-report",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "6ba7b0d7-8552-48a9-93bb-f568e27aae78",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        1184,
        -240
      ]
    },
    {
      "parameters": {
        "jsCode": "// Copy any uploaded CSV binary field to a fixed name for Extract From File.\n// Python Code nodes cannot read binary bytes (filesystem-v2); JS can pass binary through.\nconst item = $input.first();\nconst json = { ...(item.json || {}) };\nconst binary = { ...(item.binary || {}) };\n\nif (json.body && typeof json.body === 'object' && !Array.isArray(json.body)) {\n  Object.assign(json, json.body);\n  delete json.body;\n} else if (json.body && typeof json.body === 'string') {\n  try {\n    Object.assign(json, JSON.parse(json.body));\n  } catch (e) {\n    // keep raw body for optional text/csv handling downstream\n  }\n}\n\nconst uploadKeys = ['portfolioFile', 'portfolioCsv', 'file', 'data'];\nlet hasCsvBinary = false;\n\nfor (const key of uploadKeys) {\n  if (item.binary?.[key]) {\n    binary.portfolioUpload = item.binary[key];\n    hasCsvBinary = true;\n    break;\n  }\n}\n\njson._csvBinaryReady = hasCsvBinary;\n\nreturn [{ json, binary }];\n"
      },
      "id": "c7012d41-97b6-46bd-b0b3-f166ca0cbdff",
      "name": "Consolidate CSV Binary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1408,
        -240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "d8147fd8-8944-446d-9766-d7862c099faf",
              "leftValue": "={{ $json._csvBinaryReady == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f2f10c99-9bd9-4784-9968-9fcde43a5d31",
      "name": "CSV File Uploaded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1632,
        -240
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "portfolioUpload",
        "destinationKey": "portfolioCsvExtracted",
        "options": {
          "encoding": "utf8",
          "stripBOM": true,
          "keepSource": "json"
        }
      },
      "id": "7b851ea1-35d0-4938-b182-67056a66246b",
      "name": "Extract CSV File",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        1856,
        -320
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Map webhook POST body (JSON or multipart) to the workflow configuration shape.\n#\n# Portfolio CSV priority:\n#   1. portfolioCsv in JSON / multipart form fields\n#   2. portfolioCsvExtracted from Extract From File (uploaded CSV file)\n#   3. raw request body when Content-Type is text/csv (Raw Body on)\n#\n# This simplified workflow accepts CSV portfolios only (no Lime credentials).\nimport csv\nimport io\nimport json\n\nitem = _items[0]\nbody = dict(item.get(\"json\") or {})\n\n\ndef _looks_like_csv_text(text):\n    if not text or not isinstance(text, str):\n        return False\n    lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]\n    if not lines:\n        return False\n    comma_lines = sum(1 for ln in lines if \",\" in ln or \";\" in ln)\n    return comma_lines >= max(1, len(lines) // 2)\n\n\ndef _csv_has_valid_rows(text):\n    if not _looks_like_csv_text(text):\n        return False\n    first_line = text.strip().splitlines()[0]\n    delimiter = \";\" if first_line.count(\";\") > first_line.count(\",\") else \",\"\n    rows = []\n    for row in csv.reader(io.StringIO(text.strip()), delimiter=delimiter):\n        if not row or not any(str(cell).strip() for cell in row):\n            continue\n        rows.append(row)\n    if not rows:\n        return False\n    start = 0\n    try:\n        float(str(rows[0][1]).strip().replace(\",\", \".\"))\n    except (ValueError, IndexError):\n        start = 1\n    for row in rows[start:]:\n        if len(row) < 2:\n            continue\n        ticker = str(row[0]).strip()\n        if not ticker:\n            continue\n        try:\n            float(str(row[1]).strip().replace(\",\", \".\"))\n        except (TypeError, ValueError):\n            continue\n        return True\n    return False\n\n\ndef _get_content_type(headers):\n    if not isinstance(headers, dict):\n        return \"\"\n    for key, value in headers.items():\n        if str(key).lower() == \"content-type\" and value not in (None, \"\"):\n            return str(value).split(\";\", 1)[0].strip().lower()\n    return \"\"\n\n\ndef _parse_webhook_body(data):\n    raw_body = data.get(\"body\") if isinstance(data.get(\"body\"), str) else None\n    json_parsed = False\n\n    if isinstance(data.get(\"body\"), dict):\n        nested = data.pop(\"body\")\n        if isinstance(nested, dict):\n            data.update(nested)\n\n    if isinstance(data.get(\"body\"), str):\n        raw_body = data[\"body\"]\n        try:\n            parsed = json.loads(raw_body)\n        except Exception:\n            parsed = None\n        if isinstance(parsed, dict):\n            data.update(parsed)\n            json_parsed = True\n\n    return raw_body, json_parsed\n\n\ndef _collect_csv_candidates(data, raw_body, json_parsed):\n    candidates = []\n\n    explicit = (data.get(\"portfolioCsv\") or \"\").strip()\n    if explicit and _csv_has_valid_rows(explicit):\n        candidates.append((\"fields\", explicit))\n\n    extracted = (data.get(\"portfolioCsvExtracted\") or \"\").strip()\n    if extracted and _csv_has_valid_rows(extracted):\n        candidates.append((\"extracted\", extracted))\n\n    content_type = _get_content_type(data.get(\"headers\") or {})\n    if content_type == \"text/csv\" and raw_body and not json_parsed:\n        raw_csv = raw_body.strip()\n        if _csv_has_valid_rows(raw_csv):\n            candidates.append((\"raw_body\", raw_csv))\n    elif raw_body and not json_parsed and _csv_has_valid_rows(raw_body):\n        candidates.append((\"raw_body\", raw_body.strip()))\n\n    return candidates\n\n\ndef _pick_portfolio_csv(candidates):\n    priority = (\"fields\", \"extracted\", \"raw_body\")\n    by_source = {source: text for source, text in candidates}\n    for source in priority:\n        if source in by_source:\n            return by_source[source]\n    return \"\"\n\n\nraw_body, json_parsed = _parse_webhook_body(body)\nportfolio_csv = _pick_portfolio_csv(_collect_csv_candidates(body, raw_body, json_parsed))\nif portfolio_csv:\n    body[\"portfolioCsv\"] = portfolio_csv\n    body[\"portfolioSource\"] = \"csv\"\n\nbody.pop(\"_csvBinaryReady\", None)\nbody.pop(\"portfolioCsvExtracted\", None)\n\nif body.get(\"cash\") not in (None, \"\") and not str(body.get(\"manualCash\") or \"\").strip():\n    body[\"manualCash\"] = body[\"cash\"]\n\nbody.setdefault(\"runMode\", \"once\")\nbody.setdefault(\"portfolioSource\", \"csv\")\nbody[\"triggerSource\"] = \"webhook\"\n\nreturn [{\"json\": body}]\n"
      },
      "id": "2adeac8d-769d-4958-a42d-066db5c7234a",
      "name": "Normalize Webhook Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2080,
        -240
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Validate webhook configuration for the simplified Factor Loading Report workflow.\n# Portfolio is CSV-only (text field or uploaded file resolved by Normalize Webhook Input).\n\nitem = _items[0][\"json\"]\nerrors = []\n\nrun_mode = (item.get(\"runMode\") or \"once\").strip().lower()\nif run_mode not in (\"once\", \"daily\"):\n    errors.append(\"runMode must be 'once' or 'daily'.\")\n\nportfolio_source = (item.get(\"portfolioSource\") or \"csv\").strip().lower()\nif portfolio_source != \"csv\":\n    errors.append(\"portfolioSource must be 'csv'. Lime and manual JSON portfolios are not supported in this workflow.\")\n\nif not str(item.get(\"portfolioCsv\") or \"\").strip():\n    errors.append(\"portfolioCsv is required. Pass CSV text in the body or upload a CSV file.\")\n\nraw_cash = item.get(\"cash\", item.get(\"manualCash\"))\nif raw_cash not in (None, \"\"):\n    try:\n        float(raw_cash)\n    except (TypeError, ValueError):\n        errors.append(\"cash must be a number.\")\n\nstart = item.get(\"subscriptionStartDate\")\nif start not in (None, \"\") and run_mode == \"daily\":\n    try:\n        from datetime import datetime\n        datetime.strptime(str(start).strip(), \"%Y-%m-%d\")\n    except ValueError:\n        errors.append(\"subscriptionStartDate must be YYYY-MM-DD when provided.\")\n\nif errors:\n    return [{\n        \"json\": {\n            **item,\n            \"configError\": True,\n            \"configErrorMessage\": \"Configuration error(s): \" + \" | \".join(errors),\n        }\n    }]\n\nreturn [{\"json\": {**item, \"configError\": False, \"configErrorMessage\": \"\"}}]\n"
      },
      "id": "bd62a7db-d354-4f77-8b78-519b3e158920",
      "name": "Validate Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2304,
        -240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "1370d290-e210-43e8-bd1b-a545790808af",
              "leftValue": "={{ $json.configError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "6930bab4-2ac5-4f67-b509-594b4ce34fc1",
      "name": "Config Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2528,
        -240
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Build the effective portfolio from CSV text (asset, position columns).\nimport csv\nimport io\nimport json\nimport time\nfrom datetime import datetime\n\nimport pandas as pd\nimport yfinance as yf\n\nitem = _items[0][\"json\"]\nrun_mode = (item.get(\"runMode\") or \"once\").strip().lower()\n\n\ndef apply_split_adjustment(portfolio, start_date):\n    if not start_date:\n        return portfolio\n    try:\n        anchor = datetime.strptime(start_date, \"%Y-%m-%d\").date()\n    except ValueError:\n        return portfolio\n    adjusted = []\n    for entry in portfolio:\n        ticker = entry[\"ticker\"]\n        shares = float(entry[\"shares\"])\n        ratio = 1.0\n        for attempt in range(3):\n            try:\n                splits = yf.Ticker(ticker).splits\n                if splits is not None and len(splits) > 0:\n                    idx = splits.index\n                    if getattr(idx, \"tz\", None) is not None:\n                        idx = idx.tz_localize(None)\n                    splits = pd.Series(splits.values, index=idx)\n                    mask = splits.index.date > anchor\n                    relevant = splits[mask]\n                    if len(relevant) > 0:\n                        ratio = float(relevant.prod())\n                break\n            except Exception:\n                if attempt < 2:\n                    time.sleep(2)\n        adjusted.append({\"ticker\": ticker, \"shares\": shares * ratio})\n    return adjusted\n\n\ndef parse_portfolio_csv(text):\n    text = (text or \"\").strip()\n    if not text:\n        raise ValueError(\"portfolioCsv is empty\")\n    first_line = text.splitlines()[0]\n    delimiter = \";\" if first_line.count(\";\") > first_line.count(\",\") else \",\"\n    rows = []\n    for row in csv.reader(io.StringIO(text), delimiter=delimiter):\n        if not row or not any(str(c).strip() for c in row):\n            continue\n        rows.append(row)\n    if not rows:\n        raise ValueError(\"portfolioCsv has no data rows\")\n    start = 0\n    try:\n        float(str(rows[0][1]).strip().replace(\",\", \".\"))\n    except (ValueError, IndexError):\n        start = 1\n    portfolio = []\n    for row in rows[start:]:\n        if len(row) < 2:\n            continue\n        ticker = str(row[0]).strip().upper()\n        if not ticker:\n            continue\n        shares = float(str(row[1]).strip().replace(\",\", \".\"))\n        portfolio.append({\"ticker\": ticker, \"shares\": shares})\n    if not portfolio:\n        raise ValueError(\"portfolioCsv has no valid asset/position rows\")\n    return portfolio\n\n\ndef read_cash(cfg):\n    for key in (\"cash\", \"manualCash\"):\n        raw = cfg.get(key)\n        if raw in (None, \"\"):\n            continue\n        try:\n            return float(raw)\n        except (TypeError, ValueError):\n            pass\n    return 0.0\n\n\ntry:\n    portfolio = parse_portfolio_csv(item.get(\"portfolioCsv\") or \"\")\nexcept Exception as exc:\n    return [{\n        \"json\": {\n            **item,\n            \"buildError\": True,\n            \"buildErrorMessage\": f\"Invalid portfolioCsv: {exc}\",\n        }\n    }]\n\ncash = read_cash(item)\nif run_mode == \"daily\":\n    portfolio = apply_split_adjustment(portfolio, item.get(\"subscriptionStartDate\"))\n\nreturn [{\n    \"json\": {\n        **item,\n        \"buildError\": False,\n        \"buildErrorMessage\": \"\",\n        \"portfolio\": json.dumps(portfolio),\n        \"cash\": cash,\n        \"portfolioSource\": \"csv\",\n    }\n}]\n"
      },
      "id": "18e105b1-83fc-42d7-9487-658e3ad5c00d",
      "name": "Build Portfolio",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2752,
        -128
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "b80a1233-c59d-4469-a258-149eb9dc4035",
              "leftValue": "={{ $json.buildError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9a642898-e1aa-407a-9a60-aa72e63c47d2",
      "name": "Build Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2976,
        -128
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "get-proxy-ff-factors",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', from_date: $now.minus({days: 400}).toFormat('yyyy-MM-dd') } }) }}"
      },
      "id": "0f510022-767d-42d4-812a-e8ac53db5479",
      "name": "MCP - Get FF Factors",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3200,
        48
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "result_get",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', result_id: $json.result.structuredContent.result.result_id } }) }}"
      },
      "id": "ce82394e-1b6f-4801-82ac-d8787852cb32",
      "name": "MCP - Get Factor Result",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3424,
        48
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineByPosition",
        "options": {}
      },
      "id": "35149307-fb7b-4eb4-9c84-27cf45d01a8a",
      "name": "Merge Portfolio + Factors",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        3648,
        -32
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Build aligned excess-return windows for MCP get-loadings-and-alpha.\nimport json\nimport time\nfrom datetime import datetime, timedelta\n\nimport pandas as pd\nimport yfinance as yf\n\nitem = _items[0][\"json\"]\nrun_mode = (item.get(\"runMode\") or \"once\").strip().lower()\nportfolio = json.loads(item.get(\"portfolio\") or \"[]\")\ncash = float(item.get(\"cash\") or 0)\n\n\ndef fail(message, **extra):\n    return [{\n        \"json\": {\n            **item,\n            \"calcError\": True,\n            \"calcErrorMessage\": message,\n            **extra,\n        }\n    }]\n\n\ndef aggregate_positions(rows):\n    by_ticker = {}\n    for entry in rows:\n        ticker = str(entry.get(\"ticker\") or \"\").strip().upper()\n        if not ticker:\n            continue\n        by_ticker[ticker] = by_ticker.get(ticker, 0.0) + float(entry.get(\"shares\") or 0)\n\n    tickers = list(by_ticker.keys())\n    shares = [by_ticker[t] for t in tickers]\n    return tickers, shares\n\n\ndef loaded_factor_payload():\n    mcp_inner = (item.get(\"result\") or {}).get(\"structuredContent\", {}).get(\"result\", {})\n    payload = mcp_inner.get(\"payload\")\n    if payload is None and isinstance(mcp_inner, list):\n        payload = mcp_inner\n\n    if not isinstance(payload, list) or len(payload) < 2:\n        raise ValueError(\"MCP factor payload is missing factors/RF on the merged item.\")\n\n    factors_table, rf_series = payload[0], payload[1]\n    if not (factors_table.get(\"rows\") or []) or not (rf_series.get(\"rows\") or []):\n        raise ValueError(\"MCP factor payload contains empty factors or RF.\")\n\n    return factors_table, rf_series\n\n\ndef slice_table(table, date_key, dates, default_columns):\n    rows_by_date = {str(row.get(date_key)): row for row in (table.get(\"rows\") or [])}\n    missing = [d for d in dates if d not in rows_by_date]\n    if missing:\n        raise ValueError(f\"missing dates: {missing[:5]}\")\n\n    rows = [rows_by_date[d] for d in dates]\n    return {\n        \"rows\": rows,\n        \"columns\": table.get(\"columns\") or default_columns,\n        \"row_count\": len(rows),\n    }\n\n\ndef download_close(ticker, start, end):\n    last_error = \"\"\n    for attempt in range(1, 6):\n        try:\n            data = yf.download(\n                ticker,\n                start=start,\n                end=end,\n                auto_adjust=True,\n                progress=False,\n            )\n            if data.empty:\n                raise ValueError(f\"Empty data for {ticker}\")\n\n            prices = data[\"Close\"]\n            if isinstance(prices, pd.DataFrame):\n                prices = prices[ticker] if ticker in prices.columns else prices.iloc[:, 0]\n\n            prices = prices.dropna()\n            if prices.empty:\n                raise ValueError(f\"All NaN for {ticker}\")\n\n            prices.index = prices.index.tz_localize(None)\n            prices.name = ticker\n            return prices, \"\"\n        except Exception as exc:\n            last_error = str(exc)\n            if attempt < 5:\n                time.sleep(2)\n\n    return None, last_error or \"unknown Yahoo download error\"\n\n\ntickers, shares = aggregate_positions(portfolio)\nif not tickers:\n    return fail(\"Portfolio has no valid tickers after aggregation.\")\n\ntry:\n    factors_table, rf_series = loaded_factor_payload()\nexcept Exception as exc:\n    return fail(str(exc))\n\nfactor_rows = factors_table.get(\"rows\") or []\nfactor_dates = [str(row[\"Date\"]) for row in factor_rows]\nfactor_dates_set = set(factor_dates)\nlatest_factor_date = pd.to_datetime(factor_dates[-1]).date()\n\nif run_mode == \"daily\":\n    expected_yesterday = (datetime.utcnow() - timedelta(days=1)).date()\n    if latest_factor_date < expected_yesterday:\n        return fail(\"Yesterday's Fama-French factors are not yet published by the MCP service.\")\n    anchor_date = expected_yesterday\nelse:\n    anchor_date = latest_factor_date\n\ndownload_start = (anchor_date - timedelta(days=400)).strftime(\"%Y-%m-%d\")\ndownload_end = (anchor_date + timedelta(days=1)).strftime(\"%Y-%m-%d\")\n\nprice_series = []\nvalid_tickers = []\nvalid_shares = []\nticker_lengths = {}\nfailed_tickers = []\n\nfor ticker, share_count in zip(tickers, shares):\n    prices, error = download_close(ticker, download_start, download_end)\n    if error:\n        failed_tickers.append(f\"{ticker}: {error}\")\n        continue\n\n    price_series.append(prices)\n    valid_tickers.append(ticker)\n    valid_shares.append(share_count)\n    ticker_lengths[ticker] = len(prices)\n\nif failed_tickers:\n    return fail(\n        \"Failed to download price history from Yahoo for ticker(s): \"\n        + \" | \".join(failed_tickers),\n        failed_tickers=failed_tickers,\n    )\n\nprices_df = pd.concat(price_series, axis=1, join=\"outer\").sort_index().ffill().dropna()\n\nposition_value = pd.Series(0.0, index=prices_df.index)\ngross_positions = pd.Series(0.0, index=prices_df.index)\nfor ticker, share_count in zip(valid_tickers, valid_shares):\n    if ticker in prices_df.columns:\n        leg = share_count * prices_df[ticker]\n        position_value += leg\n        gross_positions += leg.abs()\n\nnet_value = position_value + cash\ngross_value = gross_positions + cash\n\nportfolio_returns = net_value.diff() / gross_value.shift(1)\nportfolio_returns = portfolio_returns[gross_value.shift(1) > 0]\nportfolio_returns = portfolio_returns.replace([float(\"inf\"), float(\"-inf\")], pd.NA).dropna()\nportfolio_returns = portfolio_returns[portfolio_returns.index.date <= anchor_date]\n\naligned = [\n    (d.strftime(\"%Y-%m-%d\"), float(v))\n    for d, v in zip(portfolio_returns.index.date, portfolio_returns.values)\n    if d.strftime(\"%Y-%m-%d\") in factor_dates_set\n]\nif not aligned:\n    return fail(\"Portfolio returns do not overlap with the MCP factor calendar.\")\n\ninsufficient_tickers = [t for t, n in ticker_lengths.items() if n < 252]\nwindows = {\"1M\": 21, \"3M\": 63, \"6M\": 126, \"1Y\": 252}\noutputs = []\n\nfor label, n_days in windows.items():\n    window = aligned[-n_days:]\n    window_dates = [d for d, _ in window]\n\n    try:\n        factors_payload = slice_table(\n            factors_table,\n            \"Date\",\n            window_dates,\n            [\"Date\", \"Mkt-RF\", \"SMB\", \"HML\", \"RMW\", \"CMA\"],\n        )\n        rf_payload = slice_table(\n            rf_series,\n            \"index\",\n            window_dates,\n            [\"index\", \"value\"],\n        )\n    except Exception as exc:\n        return fail(f\"Factors/RF do not exactly match return dates for {label}: {exc}\")\n\n    rf_by_date = {\n        str(row.get(\"index\")): float(row.get(\"value\"))\n        for row in (rf_payload.get(\"rows\") or [])\n    }\n\n    excess_window = []\n    for d, portfolio_return in window:\n        if d not in rf_by_date:\n            return fail(f\"RF is missing for {label} return date {d}.\")\n        excess_window.append((d, float(portfolio_return) - rf_by_date[d]))\n\n    outputs.append({\n        \"json\": {\n            **item,\n            \"calcError\": False,\n            \"window_label\": label,\n            \"window_from\": window[0][0] if window else None,\n            \"window_to\": window[-1][0] if window else None,\n            \"window_n_obs\": len(window),\n            \"rets_payload\": {\n                \"rows\": [{\"index\": d, \"value\": v} for d, v in excess_window],\n                \"columns\": [\"index\", \"value\"],\n                \"row_count\": len(excess_window),\n            },\n            \"factors_payload\": factors_payload,\n            \"insufficient_tickers\": insufficient_tickers,\n            \"anchor_date\": anchor_date.strftime(\"%Y-%m-%d\"),\n        }\n    })\n\nreturn outputs"
      },
      "id": "f4465677-bc17-4b27-9278-f2cc7b01d739",
      "name": "Prepare Regression Windows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3872,
        -32
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "1e9303ff-e4b4-49a0-9d79-3a0b803c024e",
              "leftValue": "={{ $json.calcError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a341584d-1c18-4d5c-b1f9-64bbb2c04d25",
      "name": "Regression Ready?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4096,
        -32
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "3cc328b3-ecb3-4639-9733-0ac99a05f1f7",
      "name": "Loop Over Items (MCP)",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        4320,
        112
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "get-loadings-and-alpha",
        "toolParameters": "={{ JSON.stringify({\n  request: {\n    _skill_contract_acknowledged: 'read-and-understood',\n    rets: $json.rets_payload,\n    factors: $json.factors_payload,\n    from_date: $json.window_from,\n    to_date: $json.window_to,\n    rets_are_excess: true\n  }\n}) }}"
      },
      "id": "5d3d11ea-e9bd-4924-881e-402dd6817cc2",
      "name": "MCP - Get Loadings & Alpha",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        4544,
        160
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Merge four MCP regression results and render the delivery artifacts.\nimport base64\nimport io\nimport json\nfrom datetime import datetime\n\nimport matplotlib.pyplot as plt\nimport pandas as pd\n\nif not _items:\n    return [{\"json\": {\"calcError\": True, \"calcErrorMessage\": \"No regression items received.\"}}]\n\nfactor_names = [\"Mkt-RF\", \"SMB\", \"HML\", \"RMW\", \"CMA\"]\nwindows = {\"1M\": 21, \"3M\": 63, \"6M\": 126, \"1Y\": 252}\nWINDOW_ORDER = [\"1M\", \"3M\", \"6M\", \"1Y\"]\nN_OBS_TO_LABEL = {21: \"1M\", 63: \"3M\", 126: \"6M\", 252: \"1Y\"}\n\nregression_results = {\n    label: {\"alpha\": None, \"loadings\": {f: None for f in factor_names}, \"n_obs\": 0}\n    for label in windows\n}\n\n\ndef extract_regression(j):\n    result = j.get(\"result\") or {}\n    if result.get(\"isError\"):\n        return None\n    inner = (result.get(\"structuredContent\") or {}).get(\"result\")\n    if isinstance(inner, dict) and inner.get(\"loadings\") is not None:\n        return inner\n    sc = result.get(\"structuredContent\") or {}\n    if isinstance(sc.get(\"loadings\"), dict):\n        return sc\n    return None\n\n\ndef resolve_label(j, idx, inner):\n    label = j.get(\"window_label\")\n    if label in regression_results:\n        return label\n    if inner:\n        n_obs = inner.get(\"n_obs\")\n        if n_obs in N_OBS_TO_LABEL:\n            return N_OBS_TO_LABEL[n_obs]\n    if idx < len(WINDOW_ORDER):\n        return WINDOW_ORDER[idx]\n    return None\n\n\nbase = {}\nfor it in _items:\n    j = it[\"json\"]\n    for key, val in j.items():\n        if key == \"result\":\n            continue\n        if val is not None and val != \"\" and key not in base:\n            base[key] = val\n\nfor idx, it in enumerate(_items):\n    j = it[\"json\"]\n    inner = extract_regression(j)\n\n    if inner is None:\n        label = resolve_label(j, idx, None)\n        if label in regression_results:\n            regression_results[label][\"n_obs\"] = j.get(\"window_n_obs\", 0)\n        continue\n\n    label = resolve_label(j, idx, inner)\n    if label not in regression_results:\n        continue\n\n    regression_results[label] = {\n        \"alpha\": inner.get(\"alpha\"),\n        \"loadings\": {f: (inner.get(\"loadings\") or {}).get(f) for f in factor_names},\n        \"n_obs\": inner.get(\"n_obs\", j.get(\"window_n_obs\", 0)),\n    }\n\nportfolio = []\ncash = 0.0\ninsufficient_tickers = []\nraw_portfolio = base.get(\"portfolio\")\nif raw_portfolio:\n    try:\n        portfolio = json.loads(raw_portfolio) if isinstance(raw_portfolio, str) else raw_portfolio\n        if not isinstance(portfolio, list):\n            portfolio = []\n    except Exception:\n        portfolio = []\ntry:\n    cash = float(base.get(\"cash\") or 0)\nexcept (TypeError, ValueError):\n    cash = 0.0\ninsufficient_tickers = base.get(\"insufficient_tickers\") or []\nif isinstance(insufficient_tickers, str):\n    try:\n        insufficient_tickers = json.loads(insufficient_tickers)\n    except Exception:\n        insufficient_tickers = []\n\n\ndef fmt_html(val):\n    if val is None:\n        return \"  N/A  \"\n    return f\"-{-val:.4f}\" if val < 0 else f\"&nbsp;{val:.4f}\"\n\n\ndef fmt_plain(val):\n    return \"N/A\" if val is None else f\"{val:.4f}\"\n\n\ndef fmt_signed(val):\n    if val is None:\n        return \"   N/A   \"\n    return f\"{val:+.4f}\"\n\n\nrows_html = \"\"\nfor f in factor_names:\n    row = f\"<tr><td style='text-align:left;'>{f}</td>\"\n    for label in windows:\n        loading = regression_results[label][\"loadings\"].get(f)\n        row += (\n            \"<td style='text-align:center; font-family: \\\"Courier New\\\", \"\n            f\"Courier, monospace; white-space:nowrap;'>{fmt_html(loading)}</td>\"\n        )\n    row += \"</tr>\"\n    rows_html += row\n\nalpha_row = \"<tr><td style='text-align:left;'>Alpha (daily)</td>\"\nfor label in windows:\n    alpha = regression_results[label][\"alpha\"]\n    alpha_row += (\n        \"<td style='text-align:center; font-family: \\\"Courier New\\\", \"\n        f\"Courier, monospace; white-space:nowrap;'>{fmt_html(alpha)}</td>\"\n    )\nalpha_row += \"</tr>\"\nrows_html += alpha_row\n\ntable_html = (\n    '<table border=\"1\" cellpadding=\"3\" cellspacing=\"0\" style=\"border-collapse:collapse;\">'\n    \"<tr><th style='text-align:left;'>Factor</th>\"\n    \"<th style='text-align:center;'>1-Month Loading</th>\"\n    \"<th style='text-align:center;'>3-Month Loading</th>\"\n    \"<th style='text-align:center;'>6-Month Loading</th>\"\n    \"<th style='text-align:center;'>1-Year Loading</th></tr>\"\n    f\"{rows_html}</table>\"\n)\n\ntable_data = []\nfor f in factor_names:\n    table_data.append([f] + [fmt_plain(regression_results[lab][\"loadings\"].get(f)) for lab in windows])\ntable_data.append([\"Alpha (daily)\"] + [fmt_plain(regression_results[lab][\"alpha\"]) for lab in windows])\ndf_table = pd.DataFrame(table_data, columns=[\"Factor\"] + list(windows.keys()))\n\nheader_cells = [\"Factor\"] + list(windows.keys())\nrows_text = []\nfor f in factor_names:\n    rows_text.append([f] + [fmt_signed(regression_results[lab][\"loadings\"].get(f)) for lab in windows])\nrows_text.append([\"Alpha (daily)\"] + [fmt_signed(regression_results[lab][\"alpha\"]) for lab in windows])\ncol_widths = [\n    max(len(str(row[i])) for row in ([header_cells] + rows_text))\n    for i in range(len(header_cells))\n]\n\n\ndef _fmt_row(row):\n    return \" | \".join(str(cell).ljust(col_widths[i]) for i, cell in enumerate(row))\n\n\nseparator = \"-+-\".join(\"-\" * w for w in col_widths)\ntable_text = \"\\n\".join([_fmt_row(header_cells), separator] + [_fmt_row(r) for r in rows_text])\n\nfig, ax = plt.subplots(figsize=(12, 0.5 * len(df_table) + 1.5))\nax.axis(\"off\")\nax.set_title(\"Loadings & Alpha\", fontsize=14, fontweight=\"bold\", pad=10)\ntable = ax.table(\n    cellText=df_table.values,\n    colLabels=df_table.columns,\n    cellLoc=\"center\",\n    loc=\"upper center\",\n)\ntable.auto_set_font_size(False)\ntable.set_fontsize(12)\ntable.scale(1.2, 1.2)\nplt.subplots_adjust(top=0.85)\nbuf = io.BytesIO()\nplt.savefig(buf, format=\"png\", dpi=150, bbox_inches=\"tight\")\nbuf.seek(0)\nplt.close(fig)\ntable_image_b64 = base64.b64encode(buf.read()).decode(\"utf-8\")\nbuf.close()\n\nportfolio_desc = \", \".join(f'{p[\"ticker\"]}:{p[\"shares\"]}' for p in portfolio)\nif cash:\n    portfolio_desc += f\" | Cash: {cash}\"\nif not portfolio_desc:\n    portfolio_desc = \"(portfolio metadata not in MCP items)\"\n\ninsufficient_note = \"\"\nif insufficient_tickers:\n    insufficient_note = (\n        \"Insufficient history (< 1 year) for: \"\n        + \", \".join(str(t) for t in insufficient_tickers)\n        + \". Longer-term loadings may be based on fewer observations.\"\n    )\n\ntoday = datetime.utcnow().date()\nreport_date = today.strftime(\"%Y-%m-%d\")\nreport_ts = datetime.utcnow().strftime(\"%Y-%m-%d %H:%M UTC\")\nfile_name = f\"factor_loading_report_{report_date}.png\"\ndata_points_text = (\n    f\"1-Month window: {regression_results['1M']['n_obs']} trading days | \"\n    f\"3-Month window: {regression_results['3M']['n_obs']} trading days | \"\n    f\"6-Month window: {regression_results['6M']['n_obs']} trading days | \"\n    f\"1-Year window: {regression_results['1Y']['n_obs']} trading days\"\n)\ncaption = (\n    \"Factor Loading Report\\n\"\n    f\"Report date: {report_date}\\n\"\n    f\"Portfolio: {portfolio_desc}\\n\\n\"\n    f\"{data_points_text}\"\n)\nif insufficient_note:\n    caption += f\"\\n\\n{insufficient_note}\"\n\nclean = {\n    k: v for k, v in base.items()\n    if k not in (\n        \"result\", \"rets_payload\", \"window_label\", \"window_from\", \"window_to\",\n        \"window_n_obs\", \"insufficient_tickers\", \"anchor_date\",\n    )\n}\n\nreturn [{\n    \"json\": {\n        **clean,\n        \"calcError\": False,\n        \"calcErrorMessage\": \"\",\n        \"report_date\": report_date,\n        \"report_timestamp\": report_ts,\n        \"portfolio_desc\": portfolio_desc,\n        \"cash\": cash,\n        \"table_html\": table_html,\n        \"table_text\": table_text,\n        \"table_image_base64\": table_image_b64,\n        \"file_name\": file_name,\n        \"caption\": caption,\n        \"subject\": f\"Factor Loading Report - {report_date}\",\n        \"insufficient_note\": insufficient_note,\n        \"data_points_1m\": regression_results[\"1M\"][\"n_obs\"],\n        \"data_points_3m\": regression_results[\"3M\"][\"n_obs\"],\n        \"data_points_6m\": regression_results[\"6M\"][\"n_obs\"],\n        \"data_points_1y\": regression_results[\"1Y\"][\"n_obs\"],\n        \"loadings_1m\": regression_results[\"1M\"][\"loadings\"],\n        \"loadings_3m\": regression_results[\"3M\"][\"loadings\"],\n        \"loadings_6m\": regression_results[\"6M\"][\"loadings\"],\n        \"loadings_1y\": regression_results[\"1Y\"][\"loadings\"],\n        \"alpha_1m\": regression_results[\"1M\"][\"alpha\"],\n        \"alpha_3m\": regression_results[\"3M\"][\"alpha\"],\n        \"alpha_6m\": regression_results[\"6M\"][\"alpha\"],\n        \"alpha_1y\": regression_results[\"1Y\"][\"alpha\"],\n    },\n    \"binary\": {\n        \"table_image\": {\n            \"data\": table_image_b64,\n            \"mimeType\": \"image/png\",\n            \"fileName\": file_name,\n        }\n    },\n}]"
      },
      "id": "d1f6362e-d451-4f4f-b357-9283b2cd7fee",
      "name": "Assemble Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4544,
        -32
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "3669c436-9a3d-479c-9965-c99bd9227530",
              "leftValue": "={{ $json.calcError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "66c3ec42-3b51-4804-9529-9b802ba3774c",
      "name": "Calc Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4768,
        -32
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Shape JSON for Respond to Webhook (success path).\n\nitem = _items[0][\"json\"]\n\nout = {\n    \"ok\": True,\n    \"report_date\": item.get(\"report_date\"),\n    \"runMode\": item.get(\"runMode\"),\n    \"portfolioSource\": item.get(\"portfolioSource\"),\n    \"portfolio\": item.get(\"portfolio_desc\"),\n    \"cash\": item.get(\"cash\"),\n    \"caption\": item.get(\"caption\"),\n    \"table_text\": item.get(\"table_text\"),\n    \"insufficient_note\": item.get(\"insufficient_note\"),\n    \"data_points_1m\": item.get(\"data_points_1m\"),\n    \"data_points_3m\": item.get(\"data_points_3m\"),\n    \"data_points_6m\": item.get(\"data_points_6m\"),\n    \"data_points_1y\": item.get(\"data_points_1y\"),\n    \"loadings_1m\": item.get(\"loadings_1m\"),\n    \"loadings_3m\": item.get(\"loadings_3m\"),\n    \"loadings_6m\": item.get(\"loadings_6m\"),\n    \"loadings_1y\": item.get(\"loadings_1y\"),\n    \"alpha_1m\": item.get(\"alpha_1m\"),\n    \"alpha_3m\": item.get(\"alpha_3m\"),\n    \"alpha_6m\": item.get(\"alpha_6m\"),\n    \"alpha_1y\": item.get(\"alpha_1y\"),\n}\n\nif item.get(\"table_image_base64\"):\n    out[\"table_image_base64\"] = item.get(\"table_image_base64\")\n\nreturn [{\"json\": out}]\n"
      },
      "id": "afb87bd2-08ab-4f1a-826a-3df0c577981e",
      "name": "Prepare Webhook Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4992,
        16
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "a32b2307-ba6e-40a1-a845-9c24c6ae8354",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5216,
        16
      ]
    },
    {
      "parameters": {
        "jsCode": "const item = $input.first().json;\nconst parts = [];\nfor (const key of ['configErrorMessage', 'buildErrorMessage', 'calcErrorMessage']) {\n  if (item[key]) parts.push(String(item[key]));\n}\nreturn [{\n  json: {\n    ...item,\n    errorText: parts.join('\\n') || 'Factor Loading Report could not be generated.',\n  },\n}];\n"
      },
      "id": "ddf2bd5d-9086-4675-b69d-a9ef54bd79cc",
      "name": "Format Error Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4992,
        -240
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Shape a JSON body for the Respond to Webhook node (error path).\nitem = _items[0][\"json\"]\nreason = (\n    item.get(\"errorText\")\n    or item.get(\"configErrorMessage\")\n    or item.get(\"buildErrorMessage\")\n    or item.get(\"calcErrorMessage\")\n    or \"Unknown error\"\n)\nreturn [{\"json\": {\"ok\": False, \"error\": reason}}]\n"
      },
      "id": "67de5e38-518c-4c12-a0e4-c28d9b1c2ec2",
      "name": "Prepare Webhook Error Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5216,
        -240
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "cab8747d-cac5-438b-8cad-9baf02bdb88f",
      "name": "Respond to Webhook (Error)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5440,
        -240
      ]
    },
    {
      "parameters": {
        "content": "## Factor Loading Report\n\nUse this workflow when an external system needs a simple HTTP API for Fama-French loadings. It accepts a CSV portfolio, calculates 1M / 3M / 6M / 1Y factor exposures and alpha, and returns JSON.\n\nThis workflow intentionally has no manual trigger, no schedule, no Telegram/email delivery, and no Lime integration. It is the cleanest workflow for app-to-app integration when the caller already knows the portfolio positions.\n\nIf you need Lime account lookup, manual reports, scheduled reports, Telegram/email delivery, or logs delivery, use `Factor Loading Report - User Workflow v2` instead.\n\n---\n\n### Production endpoint\n\n`POST /webhook/ff-ff-ff-factor-loading-report`\n\nThe workflow builds portfolio returns from submitted CSV positions, fetches proxy factors through the Fama-French MCP server, runs regressions, and responds from `Respond to Webhook`.\n\n---\n\n### Accepted input methods\n\n1. JSON body with `portfolioCsv`:\n\n```json\n{\n  \"portfolioCsv\":\"SPY,10\nGLD,-5\nQQQ,3\n\",\n  \"cash\":\"0\",\n  \"runMode\":\"once\"\n}\n```\n\n2. Raw CSV body:\n\n```http\nContent-Type: text/csv\n\nSPY,10\nGLD,-5\n```\n\n3. Multipart CSV upload. Accepted file fields: `portfolioFile`, `portfolioCsv`, `file`, or `data`.\n\nOptional fields:\n\n* `cash` or `manualCash` - cash balance; default `0`.\n* `runMode` - `once` or `daily`; default `once`.\n* `subscriptionStartDate` - optional split-adjustment anchor for daily mode.\n\nThe workflow always treats the portfolio source as CSV.\n\n---\n\n### CSV portfolio rules\n\nCSV has two columns: ticker and shares. Header row is optional; semicolon delimiter is supported.\n\n```csv\nticker,shares\nSPY,10\nGLD,-5\nQQQ,3\n```\n\nShares may be negative. Duplicate tickers are aggregated before returns are calculated. For example, `SPY,10` plus `SPY,-5` becomes one effective `SPY:5` position.\n\n---\n\n### Yahoo price history\n\nThe workflow must download Yahoo Finance prices for every effective ticker. If even one ticker cannot be downloaded, the response is `ok = false`, and no partial report is returned. The error message lists failed tickers.\n\nThis behavior is intentional: a factor report based on only part of the submitted portfolio is misleading.\n\n---\n\n### Response and MCP\n\nSuccessful JSON responses include:\n\n* `ok`, `report_date`, `table_text`, `table_image_base64`\n* `loadings_1m`, `loadings_3m`, `loadings_6m`, `loadings_1y`\n* `alpha_1m`, `alpha_3m`, `alpha_6m`, `alpha_1y`\n* `data_points_1m`, `data_points_3m`, `data_points_6m`, `data_points_1y`\n\nThe workflow uses proxy factors from:\n\n`<mcp-endpoint-url>`\n\nLink an `MCP Client (HTTP Streamable)` credential on each MCP node.\n\n`report_date` is the generation date. The regression anchor is the latest factor date used by the workflow. Errors return `ok = false` and an `error` message suitable for showing to the caller.\n",
        "height": 1400,
        "width": 1148
      },
      "id": "b50efc69-3054-48f7-8137-7a87f9868963",
      "name": "Documentation - Factor Loading Report Webhook Only",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -32,
        -896
      ]
    }
  ],
  "connections": {
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Consolidate CSV Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Consolidate CSV Binary": {
      "main": [
        [
          {
            "node": "CSV File Uploaded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CSV File Uploaded?": {
      "main": [
        [
          {
            "node": "Extract CSV File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Normalize Webhook Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract CSV File": {
      "main": [
        [
          {
            "node": "Normalize Webhook Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Webhook Input": {
      "main": [
        [
          {
            "node": "Validate Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Configuration": {
      "main": [
        [
          {
            "node": "Config Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Portfolio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Portfolio": {
      "main": [
        [
          {
            "node": "Build Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MCP - Get FF Factors",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge Portfolio + Factors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - Get FF Factors": {
      "main": [
        [
          {
            "node": "MCP - Get Factor Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - Get Factor Result": {
      "main": [
        [
          {
            "node": "Merge Portfolio + Factors",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge Portfolio + Factors": {
      "main": [
        [
          {
            "node": "Prepare Regression Windows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Regression Windows": {
      "main": [
        [
          {
            "node": "Regression Ready?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Regression Ready?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Loop Over Items (MCP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Loop Over Items (MCP)": {
      "main": [
        [
          {
            "node": "Assemble Report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MCP - Get Loadings & Alpha",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - Get Loadings & Alpha": {
      "main": [
        [
          {
            "node": "Loop Over Items (MCP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Report": {
      "main": [
        [
          {
            "node": "Calc Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calc Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Webhook Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Error Message": {
      "main": [
        [
          {
            "node": "Prepare Webhook Error Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Error Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook (Error)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "timeSavedMode": "fixed",
    "callerPolicy": "workflowsFromSameOwner",
    "executionTimeout": 600,
    "availableInMCP": false
  },
  "nodeGroups": [],
  "__quantx_template_note": "Sanitized public template. Credentials, private recipients, private webhook identifiers, and literal secrets were removed. Configure MCP and delivery credentials inside your own n8n instance."
}
