AutomationFlowsSlack & Telegram › Send Weekly Taiwan Foreign Flow Reports to Telegram Using Twse T86

Send Weekly Taiwan Foreign Flow Reports to Telegram Using Twse T86

Byfloviq @floviq on n8n.io

This workflow runs every Monday morning and pulls last week’s TWSE T86 institutional trading data, aggregates foreign investor net flows into weekly leaders and streaks, and sends a plain-text report to a Telegram chat. Runs every Monday at 08:00 Asia/Taipei on a schedule. Loads…

Cron / scheduled trigger★★★★☆ complexity12 nodesHTTP RequestTelegram
Slack & Telegram Trigger: Cron / scheduled Nodes: 12 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16101 — we link there as the canonical source.

This workflow follows the HTTP Request → Telegram recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Send a weekly Taiwan foreign-institutional flow report to Telegram",
  "_meta": {
    "edition": "free",
    "license": "personal-use-only",
    "product": "floviq \u00b7 \u53f0\u80a1\u5916\u8cc7\u9032\u51fa\u9031\u5831",
    "version": "0.1.0-free",
    "homepage": "https://floviq.tw"
  },
  "nodes": [
    {
      "id": "0e96b0e3-d358-4ece-8847-6d8f393e604f",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -368,
        48
      ],
      "parameters": {
        "width": 480,
        "height": 848,
        "content": "## Send a weekly Taiwan foreign-institutional flow report to Telegram\n\n### How it works\n\nThis workflow runs on a weekly schedule to prepare a Taiwan foreign-institutional flow report. It configures report parameters, determines the previous week's trading days while skipping configured holidays, fetches TWSE T86 data for those dates, aggregates net flows, and sends a formatted summary to Telegram.\n\n### Setup steps\n\n- Configure the Schedule Trigger for the desired weekly run time and timezone.\n- Set the Telegram chat ID, topN value, consecutiveThreshold, and holidaySkipList in the configuration node.\n- Add Telegram bot credentials to the Telegram node and ensure the bot has access to the target chat.\n- Verify the TWSE T86 HTTP request parameters match the intended market and date format.\n\n### Customization\n\nAdjust topN and consecutiveThreshold to change what appears in the report, and keep holidaySkipList updated for Taiwan market holidays."
      },
      "typeVersion": 1
    },
    {
      "id": "f887f082-9030-4d9d-8c91-671b0cef05e5",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        192,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 592,
        "height": 320,
        "content": "## Schedule and configure\n\nStarts the weekly run, sets report parameters such as Telegram chat ID, ranking size, consecutive-day threshold, and holiday skip list, then calculates the prior week's trading dates."
      },
      "typeVersion": 1
    },
    {
      "id": "5f058474-d226-4748-abcf-1dcc52ef19cf",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        992,
        48
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 400,
        "content": "## Fetch TWSE data\n\nRequests the TWSE T86 foreign institutional flow data for each resolved trading day. This node sits as its own middle-stage cluster between date preparation and aggregation."
      },
      "typeVersion": 1
    },
    {
      "id": "b9b1a093-4dbb-448e-be5f-2322c3dbf77e",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1392,
        96
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 368,
        "content": "## Aggregate weekly flows\n\nCombines the returned daily T86 datasets into last-week net foreign buy/sell summaries and slices for the report."
      },
      "typeVersion": 1
    },
    {
      "id": "fd70ba07-4914-42d1-bd18-55d355292bce",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        144
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 320,
        "content": "## Format and send report\n\nTurns the aggregated flow slices into a plain-text Telegram message and sends it to the configured chat."
      },
      "typeVersion": 1
    },
    {
      "id": "schedule-trigger",
      "name": "Weekly Report Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "notes": "Runs every Monday 08:00 (Asia/Taipei) to push last week's foreign-flow report. To change the time, edit the cron expression (fields: second minute hour day month weekday). Run Execute manually to test before activating.",
      "position": [
        240,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 0 8 * * 1"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "config-node",
      "name": "Set Report Config Parameters",
      "type": "n8n-nodes-base.set",
      "notes": "Configuration (telegramChatId is required).\n\nFields:\n- telegramChatId: target Telegram chat.id (required, numeric string)\n- topN: how many names to show per net buy / net sell list (default 5; suggested 3-10)\n- consecutiveThreshold: minimum same-direction day count for a streak (default 3; suggested 2-5)\n- holidaySkipList: national holidays, comma-separated YYYYMMDD (defaults cover 2026 plus 2027/01/01; add the next year each year-end)\n\nHow to get the bot token and chat.id:\n- Send /newbot to BotFather to get a token; the token goes in Credentials, not here.\n- After adding the bot, visit https://api.telegram.org/bot<TOKEN>/getUpdates to find chat.id.\n\nEnvironment note:\n- self-host users set any env values from docker-compose or .env\n- n8n Cloud users set them from Settings -> Variables\nThe location differs but the field names are the same.",
      "position": [
        440,
        300
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "telegramChatId",
              "name": "telegramChatId",
              "type": "string",
              "value": "REPLACE_ON_IMPORT"
            },
            {
              "id": "topN",
              "name": "topN",
              "type": "number",
              "value": 5
            },
            {
              "id": "consecutiveThreshold",
              "name": "consecutiveThreshold",
              "type": "number",
              "value": 3
            },
            {
              "id": "holidaySkipList",
              "name": "holidaySkipList",
              "type": "string",
              "value": "20260101,20260227,20260228,20260403,20260404,20260501,20260620,20260929,20261010,20270101"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "calc-last-week-days",
      "name": "Calculate Trading Days",
      "type": "n8n-nodes-base.code",
      "notes": "Resolves last Monday through last Friday (5 trading days).\n\nLogic:\n- From today (the Monday 08:00 trigger) go back 7 days = last Monday\n- Add 0..4 days = last Mon/Tue/Wed/Thu/Fri\n- Compare each against Config holidaySkipList; skip on match\n- Whole week empty (e.g. Lunar New Year) -> push an empty-week message\n\nDownstream the TWSE request runs once per trading date.",
      "position": [
        640,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Resolve last Monday..Friday (5 trading days), skipping configured holidays.\n// Today is typically the Monday trigger; last Monday = today - 7 days.\n// Holiday list comes from the Config node (no hardcoding here).\n\nconst config = $('Set Report Config Parameters').first().json;\nconst skipList = String(config.holidaySkipList || '').split(',').map(s => s.trim()).filter(Boolean);\n\nconst now = $now;\nconst lastMonday = now.minus({ days: 7 });\n\nconst items = [];\nfor (let i = 0; i < 5; i++) {\n  const d = lastMonday.plus({ days: i });\n  const yyyymmdd = d.toFormat('yyyyLLdd');\n  if (!skipList.includes(yyyymmdd)) {\n    items.push({ json: { tradingDate: yyyymmdd } });\n  }\n}\n\nif (items.length === 0) {\n  // Whole week is a holiday (rare, e.g. Lunar New Year week) -> empty-week message\n  return [{ json: { tradingDate: null, emptyWeek: true } }];\n}\n\nreturn items;\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "http-t86",
      "name": "Fetch TWSE Daily Data",
      "type": "n8n-nodes-base.httpRequest",
      "notes": "Fetches the TWSE institutional trading report (T86) for each trading day.\n\n- One GET per trading date\n- n8n default behavior: an input array runs the node per item, so 5 trading dates = 5 requests\n- Holiday / typhoon-close days return stat = no-data; the Aggregate node treats them as 0-row and skips\n- Public data, no authentication",
      "maxTries": 2,
      "position": [
        1040,
        300
      ],
      "parameters": {
        "url": "https://www.twse.com.tw/rwd/zh/fund/T86",
        "method": "GET",
        "options": {
          "timeout": 15000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        },
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "date",
              "value": "={{ $json.tradingDate }}"
            },
            {
              "name": "selectType",
              "value": "ALL"
            },
            {
              "name": "response",
              "value": "json"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "User-Agent",
              "value": "Mozilla/5.0 (compatible; n8n-workflow)"
            }
          ]
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 3000
    },
    {
      "id": "aggregate",
      "name": "Aggregate Weekly Foreign Net",
      "type": "n8n-nodes-base.code",
      "notes": "Aggregates last week's 5 trading days (or fewer) of T86 into the 4 foreign-flow slices:\n- topBuy: foreign weekly net-buy leaders (per Config topN)\n- topSell: foreign weekly net-sell leaders\n- consecutiveBuy: net buy on every valid trading day\n- consecutiveSell: net sell on every valid trading day\n\nForeign = TWSE T86 foreign + mainland-China capital, excluding foreign dealers (idx 4 field).\nUnit conversion: shares -> thousand lots (1 lot = 1000 shares, then /1000).",
      "position": [
        1440,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Aggregate last week's 5-day (or fewer) T86 -> foreign net buy/sell slices.\n// Foreign definition: foreign + mainland-China capital excluding foreign dealers (T86 idx 4)\n//   idx 2 = foreign buy shares (excl. foreign dealers)\n//   idx 3 = foreign sell shares (excl. foreign dealers)\n//   idx 4 = foreign net shares (excl. foreign dealers)  <- the net field this tool uses\n// T86 numbers are in shares (1 lot = 1000 shares), so shares / 1000 = lots.\n// Display uses thousand-lots -> shares / 1000 / 1000, matching the unit retail investors know.\n\nconst config = $('Set Report Config Parameters').first().json;\nconst topN = Number(config.topN) || 5;\nconst threshold = Number(config.consecutiveThreshold) || 3;\n\nconst days = $input.all();\n\n// empty-week fallback (Calc marked emptyWeek)\nif (days.length === 1 && days[0].json.emptyWeek === true) {\n  return [{\n    json: {\n      emptyWeek: true,\n      dataRange: 'last week',\n      dayCount: 0,\n      topBuy: [], topSell: [], consecutiveBuy: [], consecutiveSell: []\n    }\n  }];\n}\n\nconst acc = {};\nconst validDays = [];\n\ndays.forEach((day, dayIdx) => {\n  const resp = day.json;\n  if (!resp || resp.stat !== 'OK' || !Array.isArray(resp.data)) {\n    // no data that day (typhoon / late posting / unmarked holiday) -> skip the day\n    return;\n  }\n  validDays.push({ idx: dayIdx, title: resp.title });\n\n  resp.data.forEach(row => {\n    const code = String(row[0] || '').trim();\n    const name = String(row[1] || '').trim();\n    if (!code) return;\n\n    // idx 4 = foreign net shares (excl. foreign dealers); thousands separators need stripping\n    const foreignNet = parseInt(String(row[4] || '0').replace(/,/g, ''), 10) || 0;\n\n    if (!acc[code]) acc[code] = { code, name, daily: {}, weekly: 0 };\n    acc[code].daily[dayIdx] = foreignNet;\n    acc[code].weekly += foreignNet;\n  });\n});\n\nconst all = Object.values(acc);\n\n// 4 slices\nconst topBuy = [...all]\n  .filter(s => s.weekly > 0)\n  .sort((a, b) => b.weekly - a.weekly)\n  .slice(0, topN);\n\nconst topSell = [...all]\n  .filter(s => s.weekly < 0)\n  .sort((a, b) => a.weekly - b.weekly)\n  .slice(0, topN);\n\n// consecutive >= threshold days -> must be same direction on every valid day (no gaps)\n// logic: read each name's daily values across valid day idx; all > 0 = buy streak\n// then sort by absolute weekly net and cut topN to avoid an overlong / noisy message\nconst consecutiveBuy = all.filter(s => {\n  if (validDays.length < threshold) return false;\n  return validDays.every(d => (s.daily[d.idx] || 0) > 0);\n}).sort((a, b) => b.weekly - a.weekly).slice(0, topN);\n\nconst consecutiveSell = all.filter(s => {\n  if (validDays.length < threshold) return false;\n  return validDays.every(d => (s.daily[d.idx] || 0) < 0);\n}).sort((a, b) => a.weekly - b.weekly).slice(0, topN);\n\n// data range derived from the first/last valid day title\nconst parseTitle = (t) => {\n  if (!t) return null;\n  const m = String(t).match(/(\\d+)\u5e74(\\d+)\u6708(\\d+)\u65e5/);\n  if (!m) return null;\n  return `${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;\n};\nconst firstDate = parseTitle(validDays[0]?.title);\nconst lastDate = parseTitle(validDays[validDays.length - 1]?.title);\nconst dataRange = (firstDate && lastDate)\n  ? `${firstDate} ~ ${lastDate}`\n  : 'last week';\n\nreturn [{\n  json: {\n    emptyWeek: false,\n    dataRange,\n    dayCount: validDays.length,\n    threshold,\n    topBuy, topSell, consecutiveBuy, consecutiveSell\n  }\n}];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "format",
      "name": "Format Telegram Message",
      "type": "n8n-nodes-base.code",
      "notes": "Formats the 4 slices into a plain-text Telegram message.\n\n- Unit: thousand lots (1 lot = 1000 shares)\n- Risk disclaimer is force-appended (do not remove)\n- The foreign definition is noted in the footer: includes mainland-China capital, excludes foreign dealers\n- No markdown parse_mode (avoids escaping issues with stock names that contain parentheses)",
      "position": [
        1840,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// 4 slices -> Telegram plain text\n// No parse_mode (avoids escaping issues with stock names containing parentheses)\n// disclaimer is force-appended (must not be removed)\n\nconst data = $input.first().json;\n\n// empty-week message\nif (data.emptyWeek === true) {\n  const text = [\n    '\ud83d\udcc5 \u4e0a\u9031\u5916\u8cc7\u9032\u51fa\u9031\u5831',\n    '',\n    '\u4e0a\u9031\u7121\u4ea4\u6613\u65e5\u8cc7\u6599\uff08\u9023\u5047\u4f11\u5e02\uff09',\n    '',\n    '\ud83d\udccd \u8cc7\u6599\u4f86\u6e90: \u8b49\u4ea4\u6240 T86 \u4e09\u5927\u6cd5\u4eba\u8cb7\u8ce3\u8d85\u65e5\u5831',\n    '',\n    '\u203b \u672c\u8cc7\u8a0a\u50c5\u4f9b\u53c3\u8003\uff0c\u4e0d\u69cb\u6210\u6295\u8cc7\u5efa\u8b70\uff0c\u6295\u8cc7\u8acb\u81ea\u884c\u8a55\u4f30\u98a8\u96aa'\n  ].join('\\n');\n  return [{ json: { messageText: text } }];\n}\n\n// shares -> thousand lots (1 lot = 1000 shares; weekly accumulation / 1000 / 1000 = thousand lots)\nconst toThousandLots = (shares) => Math.round(shares / 1000 / 1000);\n\nconst fmtBuy = (items) => items.length > 0\n  ? items.map((it, i) => {\n      const k = toThousandLots(it.weekly);\n      return `${i + 1}. ${it.name} (${it.code})   +${k.toLocaleString()} \u5343\u5f35`;\n    }).join('\\n')\n  : '(\u672c\u9031\u7121)';\n\nconst fmtSell = (items) => items.length > 0\n  ? items.map((it, i) => {\n      const k = toThousandLots(it.weekly);\n      return `${i + 1}. ${it.name} (${it.code})   ${k.toLocaleString()} \u5343\u5f35`;\n    }).join('\\n')\n  : '(\u672c\u9031\u7121)';\n\nconst fmtConsec = (items) => items.length > 0\n  ? items.map(it => {\n      const days = Object.keys(it.daily).length;\n      return `- ${it.name} (${it.code})   ${days} \u65e5\u9023\u7e8c`;\n    }).join('\\n')\n  : '(\u672c\u9031\u7121)';\n\nconst topBuyCount = data.topBuy.length;\nconst topSellCount = data.topSell.length;\nconst threshold = data.threshold || 3;\n\nconst text = [\n  `\ud83d\udcc5 ${data.dataRange} \u5916\u8cc7\u9032\u51fa\u9031\u5831`,\n  '',\n  `\ud83d\udd3c \u5916\u8cc7\u6de8\u8cb7\u8d85 Top ${topBuyCount}\uff08\u4e0a\u9031\u7d2f\u8a08\uff09`,\n  fmtBuy(data.topBuy),\n  '',\n  `\ud83d\udd3d \u5916\u8cc7\u6de8\u8ce3\u8d85 Top ${topSellCount}\uff08\u4e0a\u9031\u7d2f\u8a08\uff09`,\n  fmtSell(data.topSell),\n  '',\n  `\ud83d\udfe2 \u9023\u7e8c\u8cb7\u8d85 \u2265 ${threshold} \u65e5`,\n  fmtConsec(data.consecutiveBuy),\n  '',\n  `\ud83d\udd34 \u9023\u7e8c\u8ce3\u8d85 \u2265 ${threshold} \u65e5`,\n  fmtConsec(data.consecutiveSell),\n  '',\n  `\ud83d\udcca \u8cc7\u6599\u5340\u9593: ${data.dataRange} \u00b7 ${data.dayCount} \u500b\u4ea4\u6613\u65e5`,\n  '\ud83d\udccd \u8cc7\u6599\u4f86\u6e90: \u8b49\u4ea4\u6240 T86 \u4e09\u5927\u6cd5\u4eba\u8cb7\u8ce3\u8d85\u65e5\u5831',\n  '\u203b\u300c\u5916\u8cc7\u300d= \u5916\u9678\u8cc7\uff08\u5883\u5916\u6cd5\u4eba + \u9678\u8cc7\uff09\u00b7 \u4e0d\u542b\u5916\u8cc7\u81ea\u71df\u5546\u90e8\u4f4d',\n  '',\n  '\u203b \u672c\u8cc7\u8a0a\u50c5\u4f9b\u53c3\u8003\uff0c\u4e0d\u69cb\u6210\u6295\u8cc7\u5efa\u8b70\uff0c\u6295\u8cc7\u8acb\u81ea\u884c\u8a55\u4f30\u98a8\u96aa'\n].join('\\n');\n\nreturn [{ json: { messageText: text } }];\n",
        "language": "javaScript"
      },
      "typeVersion": 2
    },
    {
      "id": "telegram-send",
      "name": "Send Report to Telegram",
      "type": "n8n-nodes-base.telegram",
      "notes": "Sends to Telegram. Requires:\n(1) the Credentials above linked to your Telegram Bot credential\n(2) chat_id read from the Config node\nSends plain text + emoji with no parse_mode (avoids escaping issues with stock names that contain parentheses).",
      "maxTries": 2,
      "position": [
        2040,
        300
      ],
      "parameters": {
        "text": "={{ $json.messageText }}",
        "chatId": "={{ $('Set Report Config Parameters').first().json.telegramChatId }}",
        "resource": "message",
        "operation": "sendMessage",
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 1.2,
      "waitBetweenTries": 3000
    }
  ],
  "active": false,
  "settings": {
    "timezone": "Asia/Taipei",
    "executionOrder": "v1"
  },
  "staticData": null,
  "connections": {
    "Fetch TWSE Daily Data": {
      "main": [
        [
          {
            "node": "Aggregate Weekly Foreign Net",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Weekly Report Trigger": {
      "main": [
        [
          {
            "node": "Set Report Config Parameters",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Trading Days": {
      "main": [
        [
          {
            "node": "Fetch TWSE Daily Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Telegram Message": {
      "main": [
        [
          {
            "node": "Send Report to Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Weekly Foreign Net": {
      "main": [
        [
          {
            "node": "Format Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Report Config Parameters": {
      "main": [
        [
          {
            "node": "Calculate Trading Days",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow runs every Monday morning and pulls last week’s TWSE T86 institutional trading data, aggregates foreign investor net flows into weekly leaders and streaks, and sends a plain-text report to a Telegram chat. Runs every Monday at 08:00 Asia/Taipei on a schedule. Loads…

Source: https://n8n.io/workflows/16101/ — original creator credit. Request a take-down →

More Slack & Telegram workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Slack & Telegram

. Uses googleSheets, telegram, httpRequest, wise. Scheduled trigger; 36 nodes.

Google Sheets, Telegram, HTTP Request +2
Slack & Telegram

GNCA AI News Pipeline. Uses rssFeedRead, httpRequest, telegram, errorTrigger. Scheduled trigger; 31 nodes.

RSS Feed Read, HTTP Request, Telegram +1
Slack & Telegram

GNCA AI News Pipeline. Uses rssFeedRead, httpRequest, telegram, errorTrigger. Scheduled trigger; 29 nodes.

RSS Feed Read, HTTP Request, Telegram +1
Slack & Telegram

This workflow automates plant care reminders and records using Google Sheets, Telegram, and OpenWeather API.

Google Sheets, HTTP Request, Telegram
Slack & Telegram

Apollo Data Enrichment Using Company Id to automatically finds contacts for companies listed in your Google Sheet, enriches each person with emails and phone numbers via Apollo’s API, and writes verif

Google Sheets, HTTP Request, Error Trigger +1