{
  "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
          }
        ]
      ]
    }
  }
}