{
  "name": "Factor Loading Report - Extended User Workflow (sanitized template)",
  "nodes": [
    {
      "parameters": {},
      "id": "00dc83c8-01d4-45cd-b1f9-b06d307c7d19",
      "name": "Manual Trigger (One-Time Report)",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        1216,
        608
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 2-6"
            }
          ]
        }
      },
      "id": "0f92c9db-89ce-4a14-aa67-930e7f1c1f5f",
      "name": "Schedule Trigger (Daily 07:00 NY)",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        1216,
        800
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "runMode",
              "value": "once",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "0c6cad55-d9a8-4d7d-90f0-9dc1e8831abb",
      "name": "Set Mode = Once",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1440,
        608
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "runMode",
              "value": "daily",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "29ef8fb6-fe5c-46a4-8c5b-c33ad143d848",
      "name": "Set Mode = Daily",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1440,
        800
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "delivery",
              "value": "logs",
              "type": "string"
            },
            {
              "name": "telegramChatId",
              "value": "",
              "type": "string"
            },
            {
              "name": "emailTo",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderName",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderEmail",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoApiKey",
              "value": "",
              "type": "string"
            },
            {
              "name": "portfolioSource",
              "value": "manual",
              "type": "string"
            },
            {
              "name": "manualPortfolio",
              "value": "[{\"ticker\":\"SPY\",\"shares\":10},{\"ticker\":\"PLTR\",\"shares\":5}]",
              "type": "string"
            },
            {
              "name": "manualCash",
              "value": "100",
              "type": "string"
            },
            {
              "name": "subscriptionStartDate",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeAuthType",
              "value": "credentials",
              "type": "string"
            },
            {
              "name": "limeToken",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeClientId",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeClientSecret",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeUsername",
              "value": "",
              "type": "string"
            },
            {
              "name": "limePassword",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeAccountNumber",
              "value": "",
              "type": "string"
            },
            {
              "name": "limeIncludeCash",
              "value": "false",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "1eebbf3e-cd57-4e00-9237-e3ef0ac03f03",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1664,
        704
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Validate the configuration captured by Configuration or Normalize Webhook Input.\nimport json\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\ndelivery = (item.get(\"delivery\") or \"\").strip().lower()\nif delivery not in (\"telegram\", \"email\", \"logs\", \"webhook\"):\n    errors.append(\"delivery must be 'telegram', 'email', 'logs' or 'webhook'.\")\n\nif delivery == \"telegram\" and not str(item.get(\"telegramChatId\") or \"\").strip():\n    errors.append(\"telegramChatId is required for Telegram delivery.\")\n\nif delivery == \"email\":\n    email_to = (item.get(\"emailTo\") or \"\").strip()\n    email_from = (item.get(\"brevoSenderEmail\") or \"\").strip()\n    brevo_key = (item.get(\"brevoApiKey\") or \"\").strip()\n    if \"@\" not in email_to:\n        errors.append(\"emailTo is required for Email delivery.\")\n    if \"@\" not in email_from:\n        errors.append(\"brevoSenderEmail is required for Email delivery.\")\n    if not brevo_key:\n        errors.append(\"brevoApiKey is required for Email delivery.\")\n\nportfolio_source = (item.get(\"portfolioSource\") or \"\").strip().lower()\nif portfolio_source not in (\"manual\", \"lime\", \"csv\"):\n    errors.append(\"portfolioSource must be 'manual', 'lime' or 'csv'.\")\n\nif portfolio_source == \"manual\":\n    raw_portfolio = item.get(\"manualPortfolio\") or \"[]\"\n    try:\n        parsed = json.loads(raw_portfolio) if isinstance(raw_portfolio, str) else raw_portfolio\n        if not isinstance(parsed, list) or not parsed:\n            errors.append(\"manualPortfolio must be a non-empty JSON array of {ticker, shares}.\")\n        else:\n            for entry in parsed:\n                if \"ticker\" not in entry or \"shares\" not in entry:\n                    errors.append(\"Each manualPortfolio entry needs 'ticker' and 'shares'.\")\n                    break\n    except Exception as exc:\n        errors.append(f\"manualPortfolio is not valid JSON: {exc}\")\n\nif portfolio_source == \"csv\":\n    portfolio_csv = (item.get(\"portfolioCsv\") or \"\").strip()\n    if not portfolio_csv:\n        errors.append(\"portfolioCsv is required when portfolioSource is 'csv'.\")\n\nif portfolio_source == \"lime\":\n    auth_type = (item.get(\"limeAuthType\") or \"\").strip().lower()\n    if auth_type not in (\"bearer\", \"jwt\", \"credentials\"):\n        errors.append(\"limeAuthType must be 'bearer', 'jwt' or 'credentials'.\")\n    if auth_type == \"bearer\" and run_mode == \"daily\":\n        errors.append(\n            \"Bearer tokens expire every night, so they cannot drive a daily \"\n            \"subscription. Use 'jwt' (long-lived) or 'credentials'.\"\n        )\n    if auth_type in (\"bearer\", \"jwt\") and not str(item.get(\"limeToken\") or \"\").strip():\n        errors.append(\"limeToken is required when limeAuthType is 'bearer' or 'jwt'.\")\n    if auth_type == \"credentials\":\n        for fld in (\"limeClientId\", \"limeClientSecret\", \"limeUsername\", \"limePassword\"):\n            if not str(item.get(fld) or \"\").strip():\n                errors.append(f\"{fld} is required when limeAuthType is 'credentials'.\")\n    if not str(item.get(\"limeAccountNumber\") or \"\").strip():\n        errors.append(\"limeAccountNumber is required for Lime portfolios.\")\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\": \"\"}}]"
      },
      "id": "de37d941-d5f5-4896-a382-66e8cd401457",
      "name": "Validate Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1888,
        560
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "17fb2324-f9b3-4962-bdcd-36cd073e1076",
              "leftValue": "={{ $json.configError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "acb2888d-5b72-498d-8903-63366ea84f48",
      "name": "Config Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2112,
        560
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# For \"once\" runs we always continue. For \"daily\" runs we use the official\n# NYSE schedule produced by `pandas_market_calendars`, which transparently\n# handles weekends, scheduled holidays and unscheduled closures across all\n# years. If yesterday (New York calendar) was not a trading day the\n# execution stops here (no MCP call, no report, no notification).\n\nfrom datetime import datetime, timedelta, timezone\n\nimport pandas as pd\nimport pandas_market_calendars as mcal\n\nitem = _items[0][\"json\"]\n\nif (item.get(\"runMode\") or \"once\").lower() != \"daily\":\n    return [{\"json\": item}]\n\ntry:\n    from zoneinfo import ZoneInfo\n    ny_now = datetime.now(ZoneInfo(\"America/New_York\"))\nexcept Exception:\n    # Fallback when tzdata is not bundled: approximate NY = UTC-4\n    # (correct during DST, off by 1h during standard time). Since we only\n    # need a calendar date and the trigger fires at 07:00 NY (well into\n    # the day in NY), the small offset cannot push us across midnight.\n    ny_now = datetime.now(timezone.utc) - timedelta(hours=4)\n\nyesterday = (ny_now - timedelta(days=1)).date()\n\nnyse = mcal.get_calendar(\"NYSE\")\nschedule = nyse.schedule(\n    start_date=(yesterday - timedelta(days=10)).strftime(\"%Y-%m-%d\"),\n    end_date=yesterday.strftime(\"%Y-%m-%d\"),\n)\ntrading_days = {d.date() for d in pd.to_datetime(schedule.index)}\n\nif yesterday not in trading_days:\n    # Yesterday was not a NYSE trading day; nothing to do.\n    return []\n\nreturn [{\"json\": {**item, \"tradingDayChecked\": yesterday.strftime(\"%Y-%m-%d\")}}]"
      },
      "id": "cfeb1059-a7ab-4882-915d-a56838ad4ca0",
      "name": "Trading Day Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2336,
        672
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Build the effective portfolio that will be sent to the factor calculator.\n#\n# Sources:\n#   manual — manualPortfolio JSON (+ optional manualCash)\n#   csv    — portfolioCsv text: 2 columns (asset, position); optional header row\n#   lime   — Lime API positions (+ optional cash)\nimport csv\nimport io\nimport json\nimport time\nimport urllib.parse\nimport urllib.request\nfrom datetime import datetime\n\nimport pandas as pd\nimport yfinance as yf\n\nitem = _items[0][\"json\"]\nsource = (item.get(\"portfolioSource\") or \"manual\").strip().lower()\nrun_mode = (item.get(\"runMode\") or \"once\").strip().lower()\n\n\ndef http_json(url, method=\"GET\", headers=None, data=None, timeout=30):\n    req = urllib.request.Request(url, method=method)\n    req.add_header(\"Accept\", \"application/json\")\n    for k, v in (headers or {}).items():\n        req.add_header(k, v)\n    if data is not None and not isinstance(data, (bytes, bytearray)):\n        data = data.encode(\"utf-8\")\n    with urllib.request.urlopen(req, data=data, timeout=timeout) as resp:\n        return json.loads(resp.read().decode())\n\n\ndef lime_token_from_credentials(cfg):\n    body = urllib.parse.urlencode({\n        \"grant_type\": \"password\",\n        \"client_id\": cfg[\"limeClientId\"],\n        \"client_secret\": cfg[\"limeClientSecret\"],\n        \"username\": cfg[\"limeUsername\"],\n        \"password\": cfg[\"limePassword\"],\n    })\n    resp = http_json(\n        \"https://auth.lime.co/connect/token\",\n        method=\"POST\",\n        headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n        data=body,\n    )\n    return resp[\"access_token\"]\n\n\ndef resolve_lime_auth_header(cfg):\n    auth_type = (cfg.get(\"limeAuthType\") or \"\").strip().lower()\n    if auth_type == \"credentials\":\n        token = lime_token_from_credentials(cfg)\n        return f\"Bearer {token}\"\n    token = (cfg.get(\"limeToken\") or \"\").strip()\n    if auth_type == \"bearer\":\n        return f\"Bearer {token}\"\n    return token\n\n\ndef lime_get_balances(auth_header):\n    return http_json(\n        \"https://api.lime.co/accounts\",\n        headers={\"Authorization\": auth_header},\n    )\n\n\ndef lime_get_positions(auth_header, account_number):\n    encoded = urllib.parse.quote(account_number, safe=\"\")\n    return http_json(\n        f\"https://api.lime.co/accounts/{encoded}/positions\",\n        headers={\"Authorization\": auth_header},\n    )\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 (\"manualCash\", \"cash\"):\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\nportfolio = []\ncash = 0.0\nportfolio_source_detail = source\n\nif source == \"manual\":\n    portfolio = json.loads(item.get(\"manualPortfolio\") or \"[]\")\n    if not isinstance(portfolio, list) or not portfolio:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": \"Manual portfolio is empty or invalid.\",\n            }\n        }]\n    cash = read_cash(item)\n    if run_mode == \"daily\":\n        portfolio = apply_split_adjustment(\n            portfolio, item.get(\"subscriptionStartDate\")\n        )\n\nelif source == \"csv\":\n    try:\n        portfolio = parse_portfolio_csv(item.get(\"portfolioCsv\") or \"\")\n    except Exception as exc:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Invalid portfolioCsv: {exc}\",\n            }\n        }]\n    cash = read_cash(item)\n    if run_mode == \"daily\":\n        portfolio = apply_split_adjustment(\n            portfolio, item.get(\"subscriptionStartDate\")\n        )\n\nelse:  # lime\n    try:\n        auth_header = resolve_lime_auth_header(item)\n    except Exception as exc:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Failed to obtain Lime token: {exc}\",\n            }\n        }]\n\n    account_number = (item.get(\"limeAccountNumber\") or \"\").strip()\n    try:\n        balances = lime_get_balances(auth_header)\n    except Exception as exc:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Lime /accounts call failed: {exc}\",\n            }\n        }]\n\n    account_balance = next(\n        (acc for acc in balances if acc.get(\"account_number\") == account_number),\n        None,\n    )\n    if account_balance is None:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": (\n                    f\"Account {account_number} not found in Lime balances. \"\n                    f\"Available: {[a.get('account_number') for a in balances]}\"\n                ),\n            }\n        }]\n\n    try:\n        positions = lime_get_positions(auth_header, account_number)\n    except Exception as exc:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": f\"Lime positions call failed: {exc}\",\n            }\n        }]\n\n    include_cash = str(item.get(\"limeIncludeCash\") or \"false\").strip().lower() in (\n        \"true\", \"1\", \"yes\",\n    )\n    if include_cash:\n        try:\n            cash = float(account_balance.get(\"cash\") or 0)\n        except (TypeError, ValueError):\n            cash = 0.0\n    else:\n        cash = 0.0\n\n    EXCLUDED_SECURITY_TYPES = {\"option\", \"strategy\"}\n    skipped_types = []\n    for pos in positions:\n        sec_type = (pos.get(\"security_type\") or \"\").lower()\n        if sec_type in EXCLUDED_SECURITY_TYPES:\n            skipped_types.append(sec_type)\n            continue\n        symbol = pos.get(\"symbol\")\n        qty = pos.get(\"quantity\")\n        if not symbol or qty in (None, 0):\n            continue\n        try:\n            portfolio.append({\"ticker\": str(symbol), \"shares\": float(qty)})\n        except (TypeError, ValueError):\n            continue\n\n    if not portfolio:\n        return [{\n            \"json\": {\n                **item,\n                \"buildError\": True,\n                \"buildErrorMessage\": (\n                    \"No equity positions returned by Lime for this account. \"\n                    f\"Raw positions count: {len(positions)}; \"\n                    f\"skipped (option/strategy): {len(skipped_types)}; \"\n                    f\"first raw position: {positions[0] if positions else 'none'}\"\n                ),\n            }\n        }]\n\nreturn [{\n    \"json\": {\n        **item,\n        \"buildError\": False,\n        \"buildErrorMessage\": \"\",\n        \"portfolio\": json.dumps(portfolio),\n        \"cash\": cash,\n        \"portfolioSourceDetail\": portfolio_source_detail,\n    }\n}]"
      },
      "id": "9006af89-16bf-4a06-985b-2e290f924131",
      "name": "Build Portfolio (Manual / Lime)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2560,
        672
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "274f0627-465a-4a1b-adca-9aa191e6a0b6",
              "leftValue": "={{ $json.buildError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c85cc97e-116d-4d08-ba5c-e628b33bdf05",
      "name": "Build Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        2784,
        672
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "eda038b2-6301-4fff-a620-45093793ec7c",
              "leftValue": "={{ $json.calcError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "042e74eb-453a-4f28-bfee-c108eae46215",
      "name": "Calc Error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        4800,
        768
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "ab5d0dcf-716b-4827-afdc-7a386d7f2d09",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "telegram",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "telegram"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "239a2e45-56e5-47d6-a6df-f112341144ba",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "email",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "email"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "7382e4e1-1a79-40c7-9e64-e868cd4460a6",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "logs",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "logs"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "3e97072c-2ca2-4939-97c2-22beca7c24c0",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "webhook",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "webhook"
            }
          ]
        },
        "options": {}
      },
      "id": "7d54af61-35c2-41b8-9992-4528e08904d2",
      "name": "Route Delivery",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        5024,
        784
      ]
    },
    {
      "parameters": {
        "operation": "sendPhoto",
        "chatId": "",
        "binaryData": true,
        "binaryPropertyName": "table_image",
        "additionalFields": {
          "caption": "={{ $json.caption }}"
        }
      },
      "id": "e730e3a6-5177-449f-96d2-5f3ae008c85c",
      "name": "Telegram - Send Photo",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        5248,
        528
      ]
    },
    {
      "parameters": {
        "jsCode": "// Assemble the JSON body sent to Brevo. Keeping this in a dedicated Code\n// node makes the HTTP Request configuration trivial (single body field).\nconst item = $input.first().json;\nconst html = `\n  <h2>Factor Loading Report</h2>\n  <p><strong>Report date:</strong> ${item.report_date}</p>\n  <p><strong>Portfolio:</strong> ${item.portfolio_desc}</p>\n  <h3>Loadings &amp; Alpha</h3>\n  ${item.table_html}\n  <p><em>\n    1-Month window: ${item.data_points_1m} trading days |\n    3-Month window: ${item.data_points_3m} trading days |\n    6-Month window: ${item.data_points_6m} trading days |\n    1-Year window: ${item.data_points_1y} trading days\n  </em></p>\n  ${item.insufficient_note ? `<p style=\"color:#c00;\"><em>${item.insufficient_note}</em></p>` : \"\"}\n  <p style=\"font-size:0.9em; color:#555;\">\n    <strong>Note:</strong> Alpha is the intercept from a regression of the\n    portfolio's daily excess return (total return minus the risk-free rate)\n    on the Fama-French five factors (Mkt-RF, SMB, HML, RMW, CMA). It\n    represents the average daily abnormal return not explained by the\n    factor model.\n  </p>\n  <p><em>Report generated automatically on ${item.report_timestamp}</em></p>\n`;\n\nconst payload = {\n  sender: {\n    name: item.brevoSenderName || \"Factor Loading Bot\",\n    email: item.brevoSenderEmail,\n  },\n  to: [{ email: item.emailTo }],\n  subject: item.subject,\n  htmlContent: html,\n  attachment: [\n    {\n      name: item.file_name,\n      content: item.table_image_base64,\n    },\n  ],\n};\n\nreturn [{ json: { brevoApiKey: item.brevoApiKey, brevoPayload: payload, ...item } }];\n"
      },
      "id": "1c104c9e-99a6-4e76-b11d-7d8b9faa7bf4",
      "name": "Build Brevo Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5248,
        720
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.brevo.com/v3/smtp/email",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "api-key",
              "value": ""
            },
            {
              "name": "accept",
              "value": "application/json"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.brevoPayload) }}",
        "options": {}
      },
      "id": "cb7ec3b0-11af-4e5b-adcf-7263aca59b0f",
      "name": "Brevo - Send Email",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5472,
        720
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# \"logs\" delivery channel - the user picked this option to avoid setting\n# up a Telegram bot or a Brevo account. The report is exposed in three\n# ways so it can be inspected without leaving n8n:\n#   1. an ASCII table printed to the n8n execution log via print();\n#   2. the same fields (plus the ASCII table) returned as JSON so they\n#      appear in the node's \"Output\" panel;\n#   3. the original PNG image passed through as binary - n8n previews it\n#      inline in the \"Binary\" tab of the same Output panel.\nitem = _items[0][\"json\"]\nbinary_in = _items[0].get(\"binary\") or {}\n\ntable_text = item.get(\"table_text\") or \"(table text unavailable)\"\n\nlines = [\n    \"============== Fama-French Factor Loading Report ==============\",\n    f\"Report date     : {item.get('report_date')}\",\n    f\"Run mode        : {item.get('runMode')}\",\n    f\"Portfolio source: {item.get('portfolioSourceDetail') or item.get('portfolioSource')}\",\n    f\"Portfolio       : {item.get('portfolio_desc')}\",\n    f\"Cash            : {item.get('cash')}\",\n    f\"Observations    : \"\n    f\"1M={item.get('data_points_1m')} | 3M={item.get('data_points_3m')} | \"\n    f\"6M={item.get('data_points_6m')} | 1Y={item.get('data_points_1y')}\",\n    \"\",\n    \"Loadings & Alpha:\",\n    table_text,\n]\nif item.get(\"insufficient_note\"):\n    lines.append(\"\")\n    lines.append(item[\"insufficient_note\"])\nlines.append(\"\")\nlines.append(\n    \"PNG version of the same table is available in the 'Binary' tab \"\n    \"of this node's output (key: table_image).\"\n)\nlines.append(\"===============================================================\")\n\nfor line in lines:\n    print(line)\n\nreturn [{\n    \"json\": {\n        \"report_date\": item.get(\"report_date\"),\n        \"runMode\": item.get(\"runMode\"),\n        \"portfolio\": item.get(\"portfolio_desc\"),\n        \"cash\": item.get(\"cash\"),\n        \"caption\": item.get(\"caption\"),\n        \"table_text\": 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    },\n    \"binary\": binary_in,\n}]"
      },
      "id": "58756ee9-6977-4bc1-b666-960d05a5def5",
      "name": "Log Report to n8n Logs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5248,
        912
      ]
    },
    {
      "parameters": {
        "jsCode": "// Compose a user-friendly error message and decide where to send it.\n// The previous nodes always pass the whole item through, so we have access\n// to the original configuration (delivery, telegramChatId, ...) here.\nconst item = $input.first().json;\n\nconst reason =\n  item.configErrorMessage ||\n  item.buildErrorMessage ||\n  item.calcErrorMessage ||\n  \"Unknown error\";\n\nreturn [{\n  json: {\n    ...item,\n    errorText: `Factor Loading Report could not be generated.\\n${reason}`,\n  },\n}];\n"
      },
      "id": "3488bf43-b928-4dfa-ad2f-7f047168856f",
      "name": "Format Error Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5024,
        224
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "643d1316-b062-4e3b-8584-d7678f2f34c4",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "telegram",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "telegram"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "1d0603ca-e682-4000-af94-c91cc78b6871",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "email",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "email"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "d58b8bf7-4008-4336-816d-c536d319f955",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "logs",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "logs"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "afe44d1d-5bde-45b4-a196-0c25928a0a06",
                    "leftValue": "={{ $json.delivery }}",
                    "rightValue": "webhook",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "webhook"
            }
          ]
        },
        "options": {}
      },
      "id": "0b09b218-d1da-4552-9b7c-20c340239517",
      "name": "Route Error Delivery",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        5472,
        192
      ]
    },
    {
      "parameters": {
        "chatId": "",
        "text": "={{ $json.errorText }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "id": "e1236098-6f20-4c70-987a-95da10ee56db",
      "name": "Telegram - Send Error",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        5696,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.brevo.com/v3/smtp/email",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "api-key",
              "value": ""
            },
            {
              "name": "accept",
              "value": "application/json"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({sender:{name: $json.brevoSenderName || 'Factor Loading Bot', email: $json.brevoSenderEmail},to:[{email: $json.emailTo}],subject:'Factor Loading Report - error',htmlContent:'<pre>' + $json.errorText + '</pre>'}) }}",
        "options": {}
      },
      "id": "08ebcf42-7b9b-486e-9a51-bbc8f5b9ba01",
      "name": "Brevo - Send Error",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        5696,
        192
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# \"logs\" delivery channel for failures: surface the error inside the\n# n8n execution log so the user sees it without configuring Telegram\n# or Brevo. The node also fails the execution so it shows up red.\nitem = _items[0][\"json\"]\nmsg = item.get(\"errorText\") or \"Factor Loading Report failed.\"\nprint(\"============== Fama-French Factor Loading - ERROR ==============\")\nprint(msg)\nprint(\"=================================================================\")\nraise Exception(msg)\n"
      },
      "id": "2c7ee086-d796-4d05-8fd5-1c20436dd59c",
      "name": "Log Error to n8n Logs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5696,
        384
      ]
    },
    {
      "parameters": {
        "content": "## Fama-French Factor Loading Report — User Workflow\n\nThis workflow generates a Fama-French five-factor loading and alpha report for\n**one portfolio** and delivers it either on demand or after every NYSE trading\nday. Everything is configured directly inside this workflow — no database is\nrequired.\n\n### 1. Pick how to run the workflow\n\n* **One-time report** — click *Execute workflow* (uses the `Manual Trigger`\n  node). Any portfolio source (manual / Lime Bearer / Lime JWT / Lime\n  credentials) is allowed.\n* **Daily report** — activate the workflow. The `Schedule Trigger` fires at\n  `07:00 America/New_York` Tuesday-Saturday, asks `pandas_market_calendars`\n  whether yesterday was actually a NYSE trading day, and only then calls the\n  Fama-French MCP. Daily runs do **not** support Lime *Bearer* tokens because\n  they expire every night — use *JWT* or *credentials* instead. Demo accounts\n  only issue Bearer tokens, so daily reports on a demo account require the\n  *credentials* option.\n\n> The trading-day check imports `pandas_market_calendars`; the n8n Python\n> Code node must have it available. If you self-host n8n, install it once\n> with `pip install pandas_market_calendars` inside the same environment\n> that runs the Code node.\n",
        "height": 900,
        "width": 720
      },
      "id": "02d52b47-a43e-45d3-8992-43fc7594edc0",
      "name": "Instructions",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "content": "### Lime API quick reference\n\n* Auth (password flow):\n  `POST https://auth.lime.co/connect/token`\n  body: `grant_type=password&client_id=...&client_secret=...&username=...&password=...`\n* Balances: `GET https://api.lime.co/accounts`\n* Positions: `GET https://api.lime.co/accounts/{account_number}/positions`\n* Header convention used by this workflow:\n  * `bearer` → `Authorization: Bearer <token>`\n  * `jwt`    → `Authorization: <token>`   (no prefix)\n  * `credentials` → token fetched on demand, then `Authorization: Bearer <token>`\n",
        "height": 360,
        "width": 716
      },
      "id": "2bfbecc6-795c-4710-bbd7-71cc81826957",
      "name": "Lime Reference",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        2160
      ]
    },
    {
      "parameters": {
        "content": "### 2. Fill in the **Configuration** Set node\n\nOpen the node called **Configuration** and adjust the values to your case:\n\n| Field | Description |\n|-------|-------------|\n| `delivery` | `telegram`, `email` or `logs`. Use `logs` to skip Telegram / Brevo entirely (see step 5). |\n| `telegramChatId` | Numeric chat id where the report image will be posted. |\n| `emailTo` | Recipient email address (Brevo delivery). |\n| `brevoApiKey` | Brevo v3 API key. Create at https://app.brevo.com/ → SMTP & API → API Keys. |\n| `brevoSenderEmail` | Verified sender address in Brevo. |\n| `brevoSenderName` | Display name shown to the recipient. |\n| `portfolioSource` | `manual` or `lime`. |\n| `manualPortfolio` | JSON array, e.g. `[{\"ticker\":\"SPY\",\"shares\":10},{\"ticker\":\"GLD\",\"shares\":5}]`. |\n| `manualCash` | Cash amount included in the portfolio (USD). |\n| `subscriptionStartDate` | `YYYY-MM-DD`. Used only for manual + daily runs to roll forward share counts for splits that happened after this date. Leave empty to disable split adjustment. |\n| `limeAuthType` | `bearer`, `jwt` or `credentials`. |\n| `limeToken` | Bearer or JWT token (when `limeAuthType` is `bearer` or `jwt`). |\n| `limeClientId`, `limeClientSecret`, `limeUsername`, `limePassword` | Used when `limeAuthType` is `credentials`; a fresh access token is requested before every run. |\n| `limeAccountNumber` | The Lime account, format `<lime-account-number>`. |\n| `limeIncludeCash` | `true` to add Lime cash balance to the portfolio value before regression, `false` to ignore cash. |\n\n### 3. How to create your Telegram bot\n\n1. In Telegram open `@BotFather` → `/newbot` → choose a name → save the token.\n2. Start a conversation with your new bot and send any message.\n3. Visit `https://api.telegram.org/bot<TOKEN>/getUpdates` and copy\n   `result[].message.chat.id` — that's the value of `telegramChatId`.\n4. In n8n, open the **Telegram - Send Photo** node, create a credential of\n   type *Telegram API* with the token from step 1, and select it.\n\n### 4. How to set up Brevo email delivery\n\n1. Sign up at https://www.brevo.com/ (free tier supports 300 emails/day).\n2. In *Senders, Domains & Dedicated IPs*, add and verify the address you put\n   into `brevoSenderEmail`.\n3. In *SMTP & API → API Keys*, create a v3 API key and paste it into\n   `brevoApiKey`.\n4. No further setup is needed — the **Brevo - Send Email** node uses an HTTP\n   Request, so no n8n credential is required.",
        "height": 900,
        "width": 720
      },
      "id": "0aa79c21-2a66-4482-85f9-0077cc0b5db7",
      "name": "Instructions1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        448
      ]
    },
    {
      "parameters": {
        "content": "### 5. No Telegram / Brevo? Use `delivery = logs`\n\nSet `delivery = logs` in the **Configuration** node to skip both Telegram and\nBrevo. The **Log Report to n8n Logs** node will then:\n\n* `print()` a human-readable summary (report date, portfolio, loadings caption,\n  insufficient-history note, HTML table) to the workflow's execution log; open\n  any execution from the *Executions* tab and click the node to read it.\n* return the same fields as JSON output, so they are also visible in the\n  node's output panel.\n\nThis is the easiest way to try the workflow end-to-end: only the n8n instance\nitself is needed, no external accounts.\n\n### 6. Lime broker integration\n\n* **Bearer token** — copy from the Lime web app. Rotates daily at 03:00 ET,\n  works for one-time reports only. Demo accounts can only issue Bearer tokens.\n* **JWT token** — long-lived, works for both one-time and daily runs.\n* **Credentials** — `client_id`, `client_secret`, `username` and `password`.\n  The workflow requests a fresh Bearer at `https://auth.lime.co/connect/token`\n  on every run, which is the only way to run daily reports against a demo\n  account.\n* `limeIncludeCash = true` lets the regression treat your cash balance as a\n  zero-return asset, which damps loadings proportionally to cash weight. Set\n  it to `false` to compute loadings on the equity portion only.\n\n### 7. Schedule / time zone\n\nThe workflow `settings.timezone` is already set to `America/New_York`, so the\ncron expression `0 7 * * 2-6` fires at 07:00 New York time on Tuesday through\nSaturday regardless of the n8n instance default. Open *Settings* and confirm\nthe value if you ever clone the workflow.\n\n### 8. MCP credentials\n\nThe three MCP nodes (`MCP - Get FF Factors1`, `MCP - Get Factor Result`,\n`MCP - Get Loadings & Alpha`) call the public Fama-French MCP endpoint at\n`<mcp-endpoint-url>`.\nIf your n8n instance has the [n8n-nodes-mcp](https://www.npmjs.com/package/n8n-nodes-mcp)\ncommunity node installed, open each MCP node once and either link an existing\n*MCP Client (HTTP Streamable)* credential or create a new one (the endpoint\ndoes not require authentication, an empty credential is enough).\n\n### 9. What gets sent\n\nFor both Telegram and email the report contains a PNG of the loadings &\nalpha table (1M / 3M / 6M / 1Y windows for `Mkt-RF`, `SMB`, `HML`, `RMW`,\n`CMA`), a portfolio descriptor, the report date, and a note if any ticker has\nless than one year of history.\n",
        "height": 900,
        "width": 720
      },
      "id": "f03b7de5-dca7-46f0-8a89-eec57d5bb6f6",
      "name": "Instructions2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        1264
      ]
    },
    {
      "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": "17046433-a6e9-41c1-b4d9-b843bc3f2950",
      "name": "MCP - Get Factor Result",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3232,
        816
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Build aligned return windows for the downstream MCP regression node.\n#\n# Manual portfolios — gross P&L (Variant 1):\n#   net_t   = sum(shares_i * price_i,t) + cash\n#   gross_t = sum(|shares_i * price_i,t|) + cash\n#   r_t     = (net_t - net_{t-1}) / gross_{t-1}\n#\n# Lime with limeIncludeCash — legacy net return (gross = net):\n#   gross_t = net_t   # avoids double-counting shorts via cash in the denominator\n#\n# All other cases — gross P&L (Variant 1):\n#   gross_t = sum(|shares_i * price_i,t|) + cash\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_source = (item.get(\"portfolioSource\") or \"manual\").strip().lower()\nlime_include_cash = str(item.get(\"limeIncludeCash\") or \"false\").strip().lower() in (\n    \"true\", \"1\", \"yes\",\n)\nportfolio = json.loads(item.get(\"portfolio\") or \"[]\")\ncash = float(item.get(\"cash\") or 0)\n\nmcp_inner = (item.get(\"result\") or {}).get(\"structuredContent\", {}).get(\"result\", {})\npayload = mcp_inner.get(\"payload\")\nif payload is None and isinstance(mcp_inner, list):\n    payload = mcp_inner\nif not payload:\n    return [{\"json\": {**item, \"calcError\": True,\n                      \"calcErrorMessage\": \"MCP factor payload is missing on the merged item.\"}}]\n\nfactors_table = payload[0]\nfactor_dates = [r[\"Date\"] for r in factors_table[\"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 [{\"json\": {**item, \"calcError\": True,\n                          \"calcErrorMessage\": (\n                              \"Yesterday's Fama-French factors are not yet published \"\n                              \"by the MCP service. The workflow will retry on the \"\n                              \"next scheduled run.\")}}]\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\ntickers = [p[\"ticker\"] for p in portfolio]\nshares = [p[\"shares\"] for p in portfolio]\nprice_series = []\nvalid_tickers = []\nvalid_shares = []\nticker_lengths = {}\n\nfor ticker, share_count in zip(tickers, shares):\n    for attempt in range(1, 6):\n        try:\n            data = yf.download(\n                ticker,\n                start=download_start,\n                end=download_end,\n                auto_adjust=True,\n                progress=False,\n            )\n            if data.empty:\n                raise ValueError(f\"Empty data for {ticker}\")\n            prices = data[\"Close\"]\n            if isinstance(prices, pd.DataFrame):\n                prices = prices[ticker] if ticker in prices.columns else prices.iloc[:, 0]\n            prices = prices.dropna()\n            if prices.empty:\n                raise ValueError(f\"All NaN for {ticker}\")\n            prices.index = prices.index.tz_localize(None)\n            prices.name = ticker\n            price_series.append(prices)\n            valid_tickers.append(ticker)\n            valid_shares.append(share_count)\n            ticker_lengths[ticker] = len(prices)\n            break\n        except Exception:\n            if attempt < 5:\n                time.sleep(2)\n\nif not price_series:\n    return [{\"json\": {**item, \"calcError\": True,\n                      \"calcErrorMessage\": \"Failed to download price history for any ticker.\"}}]\n\nprices_df = pd.concat(price_series, axis=1, join=\"outer\").sort_index()\nprices_df = prices_df.ffill().dropna()\n\nposition_value = pd.Series(0.0, index=prices_df.index)\ngross_positions = pd.Series(0.0, index=prices_df.index)\nfor i, ticker in enumerate(valid_tickers):\n    if ticker in prices_df.columns:\n        leg = valid_shares[i] * prices_df[ticker]\n        position_value += leg\n        gross_positions += leg.abs()\n\nnet_value = position_value + cash\nif portfolio_source == \"lime\" and lime_include_cash:\n    gross_value = net_value\nelse:\n    gross_value = gross_positions + cash\n\npnl = net_value.diff()\nprior_gross = gross_value.shift(1)\nvalid = prior_gross > 0\nportfolio_returns = (pnl[valid] / prior_gross[valid])\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 [{\"json\": {**item, \"calcError\": True,\n                      \"calcErrorMessage\": \"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 = []\nfor label, n_days in windows.items():\n    window = aligned[-n_days:]\n    rets_payload = {\n        \"rows\": [{\"index\": d, \"value\": v} for d, v in window],\n        \"columns\": [\"index\", \"value\"],\n        \"row_count\": len(window),\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\": rets_payload,\n            \"insufficient_tickers\": insufficient_tickers,\n            \"anchor_date\": anchor_date.strftime(\"%Y-%m-%d\"),\n        }\n    })\nreturn outputs"
      },
      "id": "43e85fd7-309c-4660-b14d-f8f52c65f416",
      "name": "Prepare Regression Windows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3680,
        752
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "b6e6f55f-03ff-441a-bd1c-b72c6d967d3d",
              "leftValue": "={{ $json.calcError == true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f3040019-9681-45f1-bcf4-99b61170d45a",
      "name": "Regression Ready?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        3904,
        752
      ]
    },
    {
      "parameters": {
        "connectionType": "http",
        "uriOverride": "<mcp-endpoint-url>",
        "operation": "executeTool",
        "toolName": "get-loadings-and-alpha",
        "toolParameters": "={{ JSON.stringify({ request: { _skill_contract_acknowledged: 'read-and-understood', rets: $json.rets_payload, from_date: $json.window_from, to_date: $json.window_to, rets_are_excess: false } }) }}"
      },
      "id": "00543f22-e810-4fbf-be4b-439f74e1d83c",
      "name": "MCP - Get Loadings & Alpha",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        4352,
        960
      ],
      "executeOnce": false
    },
    {
      "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": "d35cb860-f906-4086-9e3e-f96e5dbbf3af",
      "name": "Assemble Report",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4352,
        768
      ]
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineByPosition",
        "options": {}
      },
      "id": "cd3f0a6a-2a71-4eef-b7c9-0964c6f174b6",
      "name": "Merge Portfolio + Factors1",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        3456,
        752
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "dde2b7ab-3cfe-48c4-9294-b6089cec6421",
      "name": "Loop Over Items (MCP)",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        4128,
        912
      ]
    },
    {
      "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": "95d9294a-2f26-4df9-8027-bf6a619917c5",
      "name": "MCP - Get FF Factors",
      "type": "n8n-nodes-mcp.mcpClient",
      "typeVersion": 1,
      "position": [
        3008,
        816
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "factor-loading-report-advanced",
        "responseMode": "responseNode",
        "options": {
          "rawBody": false
        }
      },
      "id": "088ff627-643b-4edf-8441-b9c369916983",
      "name": "Webhook Trigger",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        768,
        368
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Map a Webhook POST body (JSON or multipart) to the same shape as Configuration.\n#\n# CSV portfolio sources (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# Uploaded files are read by Extract From File (JS can pass binary; Python cannot).\n# Aliases: cash -> manualCash. delivery defaults to \"webhook\".\nimport csv\nimport io\nimport json\nimport re\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.setdefault(\"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(\"delivery\", \"webhook\")\nbody[\"triggerSource\"] = \"webhook\"\n\nif body.get(\"manualPortfolio\") is not None and not isinstance(body.get(\"manualPortfolio\"), str):\n    body[\"manualPortfolio\"] = json.dumps(body[\"manualPortfolio\"])\n\nreturn [{\"json\": body}]"
      },
      "id": "e1d823c0-2142-4193-bebb-801fb28fea0c",
      "name": "Normalize Webhook Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1664,
        448
      ]
    },
    {
      "parameters": {
        "language": "pythonNative",
        "pythonCode": "# Shape a JSON body for the Respond to Webhook node (success path).\nimport base64\n\nitem = _items[0][\"json\"]\nbinary_in = _items[0].get(\"binary\") or {}\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\n# Optional: uncomment the block below to include the report PNG as base64 in the webhook JSON response.\n#img = binary_in.get(\"table_image\")\n#if img and img.get(\"data\"):\n#    out[\"table_image_base64\"] = img[\"data\"]\n#    out[\"table_image_mime\"] = img.get(\"mimeType\") or \"image/png\"\n\nreturn [{\"json\": out}]\n"
      },
      "id": "11bc606b-ffee-4fd0-a9c8-5a3b5a929b64",
      "name": "Prepare Webhook Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5248,
        1104
      ]
    },
    {
      "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": "3de683cb-ea9b-41e1-8946-5d577ffc20af",
      "name": "Prepare Webhook Error Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        5696,
        576
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "3d0f3dac-0151-424c-b265-6a1ae8fdd3d3",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5472,
        1104
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ $json }}",
        "options": {}
      },
      "id": "c46a48bb-ead5-478a-b42d-86327a99fb97",
      "name": "Respond to Webhook (Error)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        5920,
        576
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "delivery",
              "value": "logs",
              "type": "string"
            },
            {
              "name": "telegramChatId",
              "value": "",
              "type": "string"
            },
            {
              "name": "emailTo",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderName",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderEmail",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoApiKey",
              "value": "",
              "type": "string"
            },
            {
              "name": "runMode",
              "value": "={{ $('Validate Configuration').first().json.runMode }}",
              "type": "string"
            },
            {
              "name": "portfolioSource",
              "value": "manual",
              "type": "string"
            },
            {
              "name": "portfolio",
              "value": "={{ $('Build Portfolio (Manual / Lime)').first().json.portfolio }}",
              "type": "string"
            },
            {
              "name": "cash",
              "value": "={{ $('Build Portfolio (Manual / Lime)').first().json.cash }}",
              "type": "number"
            },
            {
              "name": "portfolio_desc",
              "value": "={{ (() => { const bp = $('Build Portfolio (Manual / Lime)').first().json; const pf = JSON.parse(bp.portfolio || '[]'); const desc = pf.map(p => p.ticker + ':' + p.shares).join(', '); const cash = parseFloat(bp.cash || 0); return cash ? desc + ' | Cash: ' + cash : desc; })() }}",
              "type": "string"
            },
            {
              "name": "insufficient_tickers",
              "value": "={{ $('Prepare Regression Windows').first().json.insufficient_tickers || [] }}",
              "type": "array"
            },
            {
              "name": "insufficient_note",
              "value": "={{ (() => { const t = $('Prepare Regression Windows').first().json.insufficient_tickers || []; return t.length ? 'Insufficient history (< 1 year) for: ' + t.join(', ') + '. Longer-term loadings may be based on fewer observations.' : ''; })() }}",
              "type": "string"
            },
            {
              "name": "caption",
              "value": "={{ (() => { const bp = $('Build Portfolio (Manual / Lime)').first().json; const pf = JSON.parse(bp.portfolio || '[]'); let desc = pf.map(p => p.ticker + ':' + p.shares).join(', '); const cash = parseFloat(bp.cash || 0); if (cash) desc += ' | Cash: ' + cash; const j = $json; const t = $('Prepare Regression Windows').first().json.insufficient_tickers || []; const note = t.length ? '\\n\\nInsufficient history (< 1 year) for: ' + t.join(', ') + '. Longer-term loadings may be based on fewer observations.' : ''; return 'Factor Loading Report\\nReport date: ' + j.report_date + '\\nPortfolio: ' + desc + '\\n\\n1-Month window: ' + j.data_points_1m + ' trading days | 3-Month window: ' + j.data_points_3m + ' trading days | 6-Month window: ' + j.data_points_6m + ' trading days | 1-Year window: ' + j.data_points_1y + ' trading days' + note; })() }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "3c0a36e5-9bc0-46c1-9d84-f010e5bc8197",
      "name": "Attach Configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4576,
        768
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "name": "delivery",
              "value": "logs",
              "type": "string"
            },
            {
              "name": "telegramChatId",
              "value": "",
              "type": "string"
            },
            {
              "name": "emailTo",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderName",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoSenderEmail",
              "value": "",
              "type": "string"
            },
            {
              "name": "brevoApiKey",
              "value": "",
              "type": "string"
            },
            {
              "name": "runMode",
              "value": "={{ $('Validate Configuration').first().json.runMode }}",
              "type": "string"
            },
            {
              "name": "portfolioSource",
              "value": "manual",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "5f737d92-36b8-4739-ae8e-644198feeced",
      "name": "Attach Configuration (Error)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        5248,
        224
      ]
    },
    {
      "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": "b55e5f57-e64f-48bc-a802-928d6975c8d1",
      "name": "Consolidate CSV Binary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        992,
        368
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "b2c3d4e5-2222-4333-8444-555566667777",
              "leftValue": "={{ $json._csvBinaryReady }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7bee71d3-4995-44c1-9d3c-da32dc561385",
      "name": "CSV File Uploaded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1216,
        368
      ]
    },
    {
      "parameters": {
        "operation": "text",
        "binaryPropertyName": "portfolioUpload",
        "destinationKey": "portfolioCsvExtracted",
        "options": {
          "encoding": "utf8",
          "stripBOM": true,
          "keepSource": "json"
        }
      },
      "id": "3bcb71fa-0618-4ba0-86df-7f53f9e4a567",
      "name": "Extract CSV File",
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1,
      "position": [
        1504,
        272
      ]
    }
  ],
  "connections": {
    "Manual Trigger (One-Time Report)": {
      "main": [
        [
          {
            "node": "Set Mode = Once",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Schedule Trigger (Daily 07:00 NY)": {
      "main": [
        [
          {
            "node": "Set Mode = Daily",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Mode = Once": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Mode = Daily": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configuration": {
      "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": "Trading Day Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Trading Day Check": {
      "main": [
        [
          {
            "node": "Build Portfolio (Manual / Lime)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Portfolio (Manual / Lime)": {
      "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 + Factors1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calc Error?": {
      "main": [
        [
          {
            "node": "Format Error Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Route Delivery",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Delivery": {
      "main": [
        [
          {
            "node": "Telegram - Send Photo",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build Brevo Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Report to n8n Logs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Webhook Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Brevo Payload": {
      "main": [
        [
          {
            "node": "Brevo - Send Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Error Message": {
      "main": [
        [
          {
            "node": "Attach Configuration (Error)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Error Delivery": {
      "main": [
        [
          {
            "node": "Telegram - Send Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Brevo - Send Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to n8n Logs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Webhook Error Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MCP - Get Factor Result": {
      "main": [
        [
          {
            "node": "Merge Portfolio + Factors1",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "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
          }
        ]
      ]
    },
    "MCP - Get Loadings & Alpha": {
      "main": [
        [
          {
            "node": "Loop Over Items (MCP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Portfolio + Factors1": {
      "main": [
        [
          {
            "node": "Prepare Regression Windows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Report": {
      "main": [
        [
          {
            "node": "Attach Configuration",
            "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 FF Factors": {
      "main": [
        [
          {
            "node": "MCP - Get Factor Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook Trigger": {
      "main": [
        [
          {
            "node": "Consolidate CSV Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Webhook Error Response": {
      "main": [
        [
          {
            "node": "Respond to Webhook (Error)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Webhook Input": {
      "main": [
        [
          {
            "node": "Validate Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach Configuration": {
      "main": [
        [
          {
            "node": "Calc Error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach Configuration (Error)": {
      "main": [
        [
          {
            "node": "Route Error Delivery",
            "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
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "timeSavedMode": "fixed",
    "timezone": "America/New_York",
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false,
    "executionTimeout": 600
  },
  "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."
}
