跳轉到

八大行庫/券商分點 Participant Flows — Frontend Integration Guide

Backend branch: feat/hub-participant-flows → cortex/dev(C2 shard; C1 provides the table + sync);§4c(個股 × 分點逐日)另於 feat/1658-broker-branch-daily → cortex/dev 加入。 API base: http://localhost:8000(dev)/ production base 同既有 Auth: 五個 endpoint 全部需登入(Authorization: Bearer <access_token>)。無效 token → 401;未帶 Authorization header → 422。


0. 資料來源與更新頻率

taiwan.hub_participant_flows 由排程於每日 01:00(Asia/Taipei)從 Cortex Data Hub 同步 D-1..D-3(scripts/hub_participant_flows_sync.py)。八大行庫資料無保留期上限;券商分點保留 116 天(BROKER_BRANCH_RETENTION_DAYS),116 天以外的分點日期單純落在窗口之外——但「查不到」時的 HTTP 狀態因端點而異,不是一律 200 + 空陣列:個股 × 窗口(§4,A)窗口內無資料回 200 + items: [];分點 × 窗口(§4b,B)與個股 × 分點逐日(§4c,C)窗口內無資料回的是 404。串接時務必依端點分開處理 404,不能假設三支都是 200 + 空陣列。

Auth 更新:本次改版後 participant-flows 共五個 endpoint(八大行庫個股/八大行庫排行/分點個股/分點分點/個股 × 分點逐日),五個都需登入。


1. 七項假設核對(web 依這些假設實作;#1/#4/#5/#6 已因本次分點改版更新)

# web 假設 結論 說明
1 broker-branch 賣方 item 帶 buy_avg_price(與買方同一 item 型別)vs 帶獨立的 sell_avg_price 已失效(本次改版取消買/賣雙榜) broker-branch 個股 endpoint 不再回 buy/sell 兩個榜,而是一個 items 陣列、每個分點一列,同時帶 buy_avg_price 與 sell_avg_price 兩個欄位(各自為該窗口的買進/賣出 VWAP,可為 null)。原本「賣方沿用 buy_avg_price 欄位名」的設計選擇隨雙榜一起移除。
2 每個 Decimal 金額/股數欄位(含股數)在 wire 上都序列化成字串或 null CONFIRMED buy_shares/sell_shares/net_shares(BIGINT)與 buy_value/sell_value/net_value(NUMERIC(38,8))、buy_avg_price(NUMERIC(32,12))在 API 回應裡都是字串或 null,包含股數。字串帶 DB 欄位的完整精度(例如 NUMERIC(38,8) 一律 8 位小數),不是原始送進 DB 的字面字串。
3 rank 是 int,stock_code/ticker/name/listing_type 是字串 部分 REFUTED(name/listing_type 可為 null) rank(int)、stock_code/ticker(字串)如假設;但 name 對應 taiwan.stocks.name,該欄位 nullable,listing_type 對指數等代碼為 NULL,兩者在 wire 上都可能是 null,web 端渲染需容錯(型別應為 string \| null)。
4 broker-branch 回應會回 side 且有 trade_date REFUTED(A/B 兩者都沒有;C 是例外) 沒有 side(side 只存在於 government-bank ranking),本次改版後個股 × 窗口(§4,A)與分點 × 窗口(§4b,B)也沒有 trade_date:兩支改回窗口欄位 window(1/5/20/60)、start_date、end_date、actual_days,web 型別需移除 trade_date、加入這四個欄位。個股 × 分點逐日(§4c,C)是例外:C 沒有 window,改回逐日回應,items[] 每列都帶 trade_date(詳見 §4c)。
5 個股 endpoint 沒帶 days 時預設 DEFAULT_HISTORY_DAYS=30;broker-branch 預設該股最新可得日 部分 REFUTED(broker-branch A/B 已無單日概念) government-bank/stock 的 days 預設 30、上限 3650(今日 Asia/Taipei 往回算日曆天)不變;個股 × 窗口(§4,A)與分點 × 窗口(§4b,B)不再有 date 參數,改為必填 window(1/5/20/60,無預設值,沒帶或帶其他值都回 422),窗口是整個 dataset 最近 N 個交易日(見 §4)。個股 × 分點逐日(§4c,C)不用 window,改用自由整數的必填 days(同樣是 dataset 最近 N 個交易日,但不限 1/5/20/60,詳見 §4c)。
6 ranking limit 上限 100、預設 50 REFUTED(預設值;broker 另有上限) government-bank ranking 上限 100 正確、預設值是 20(沿用 institutional.py 的 DEFAULT_RANKING_LIMIT=20)。個股 × 窗口(§4,A)與分點 × 窗口(§4b,B)的 limit 預設與上限都是 5000(DEFAULT_BROKER_BRANCH_LIMIT / MAX_BROKER_BRANCH_LIMIT,沿用 stocks.py:1404 的 list endpoint 上限)——全市場約 820 個分點、約 2,001 檔個股都在一頁內,web 端請直接不帶 limit 取全量、前端自行分頁;PARTICIPANT_BROKER_TABLE_LIMIT 這個 web 常數應移除。個股 × 分點逐日(§4c,C)沒有 limit 參數:回應列數固定等於 actual_days(窗口內交易日數),不分頁也不截斷。
7 government-bank 個股回應把 total(市場合計)與 banks(各行庫明細)分開回傳 CONFIRMED total=participant_kind=government_bank_total(市場合計,每個 trade_date 一列);banks=participant_kind=government_bank(各行庫明細,每個 trade_date 有 1..8 列,只有當日有交易的行庫才出現,不會補成固定 8 列)。與 web 型別 GovernmentBankStockResponse<TNumeric> 一致。

2. Endpoint 一:GET /api/participant-flows/government-bank/stock/{ticker}

Query Type Required Default Notes
days integer no 30 1..3650;今日(Asia/Taipei)往回抓的日曆天數

Error:

情境 HTTP
未帶/無效 token 422 / 401
ticker 解析失敗(含裸碼無 .TW/.TWO)或非台股(AAPL) 422
ticker 不在 taiwan.stocks(含 hub 才有的 ETF/權證代碼) 404
ticker 存在但視窗內無列 200,total/banks 皆為 []

Response(完整範例):

{
  "ticker": "2330.TW",
  "total": [
    {
      "trade_date": "2026-09-01",
      "buy_shares": "1000000",
      "sell_shares": "400000",
      "net_shares": "600000",
      "buy_value": "50000000.50000000",
      "sell_value": "20000000.25000000",
      "net_value": "30000000.25000000"
    }
  ],
  "banks": [
    {
      "trade_date": "2026-09-01",
      "participant_id": "BANK1",
      "participant_name": "第一銀行",
      "buy_shares": "600000",
      "sell_shares": "100000",
      "net_shares": "500000",
      "buy_value": "30000000.00000000",
      "sell_value": "5000000.00000000",
      "net_value": "25000000.00000000"
    },
    {
      "trade_date": "2026-09-01",
      "participant_id": "BANK2",
      "participant_name": "合作金庫",
      "buy_shares": "400000",
      "sell_shares": "300000",
      "net_shares": "100000",
      "buy_value": "20000000.00000000",
      "sell_value": "15000000.00000000",
      "net_value": "5000000.00000000"
    }
  ]
}

3. Endpoint 二:GET /api/participant-flows/government-bank/ranking

Query Type Required Default Notes
date date no 最新可得日 YYYY-MM-DD
side buy|sell no buy buy=只取 net_value > 0 依 DESC;sell=只取 net_value < 0 依 ASC
limit integer no 20(見上表 #6) 1..100

INNER JOIN taiwan.stocks:不在 taiwan.stocks 的 ETF/權證代碼不進榜;limit 是「可 JOIN 到的前 N 名」,不是全體候選的前 N 名。buy 只收 net_value > 0 的個股、sell 只收 net_value < 0 的個股,兩榜互斥:同一檔不會同時出現在買超榜與賣超榜;net_value 為 0 或 null 的個股兩榜都不出現(賣超榜不會被淨買超個股補滿)。全表無資料時 trade_date 為 null、items 為 [](仍是 200)。net_value 相同時以 stock_code 升冪決定名次(排序次鍵),同分個股的排序在多次呼叫間穩定;listing_type 為 null 的代碼 ticker 退回裸代碼。若該裸代碼是純數字的個股代碼(防禦情境,現行資料不存在),它不能回填 /api/participant-flows/government-bank/stock/{ticker}——該端點要求 .TW/.TWO 後綴,會回 422;現行資料中唯一 listing_type 為 NULL 的是指數 ^TWII,指數代碼不需後綴,可直接查詢。

Response(完整範例):

{
  "trade_date": "2026-09-01",
  "side": "buy",
  "items": [
    {
      "rank": 1,
      "stock_code": "2330",
      "ticker": "2330.TW",
      "name": "台積電",
      "listing_type": "tw",
      "net_shares": "600000",
      "net_value": "30000000.25000000"
    },
    {
      "rank": 2,
      "stock_code": "2454",
      "ticker": "2454.TW",
      "name": "聯發科",
      "listing_type": "tw",
      "net_shares": "150000",
      "net_value": "9000000.00000000"
    }
  ]
}

4. Endpoint 三:GET /api/participant-flows/broker-branch/stock/{ticker}(個股 × 窗口 × 全部分點)

Breaking change:本 endpoint 已改版——移除 date 參數與 buy/sell 雙榜,改為「一個窗口、一個 items 陣列、每個分點一列」。

Query Type Required Default Notes
window integer yes (無) 只接受 1/5/20/60(1/5/20 來自 web 的 STRONG_STOCK_PERIODS;60 保留給 Cortex-web#296 的主力買點卡)。其他值或未帶 → 422
limit integer no 5000 1..5000;全市場約 820 個分點,一頁即可取全量;截斷發生在 ORDER BY net_value DESC NULLS LAST 之後,limit 設小時只會留下淨買超最多的前幾名

窗口語意(本次改版最重要的一點):window 不是「往回 N 個日曆天」,也不是「該股自己的最近 N 天」,而是整個券商分點 dataset 最近 N 個 distinct trade_date(與三大法人 amount ranking 的窗口建構方式相同)。

  • 回應的 start_date/end_date/actual_days 描述這個 dataset 層級的窗口;資料不足 N 天時 actual_days < window(例如剛上線只同步了 3 天,window=5 會回 actual_days: 3)。Dataset 若無任何交易日時,start_date/end_date 為 null、actual_days 為 0;web 型別需為 string | null。
  • 後果:某檔個股若在最新市場日沒有任何分點列(當天沒成交、或 hub 該股當天沒資料),window=1 會回 200 + items: [],即使它有更早日期的分點資料。這是刻意的——窗口是市場層級的,不會為個股各自往回找。要看更早的資料請改用 window=5/20/60。
  • 窗口內任一日股數非零但金額為 null 時,該側 *_value 與 *_avg_price 回 null(股數仍加總):上游有時只給股數不給金額(例如某日買進 5,072 股但 buy_value 是 null),把該日略過再加總會算出被低估的金額與假的均價,因此該側整個窗口回 null 表示「不可知」。買/賣兩側各自判斷,股數為 0 的日子(沒有金額可缺)不影響該側;淨額則是該日買或賣任一側股數非零且 net_value 為 null 時回 null(淨股數為 0 不代表無交易——例如同日買 100 股賣 100 股),只有買賣兩側股數皆為 0 的日子不影響。net_value 為 null 的列排序時被排到最後(ORDER BY net_value DESC NULLS LAST, participant_id)。
  • buy_avg_price = SUM(buy_value) / NULLIF(SUM(buy_shares), 0)、sell_avg_price 同理,是該窗口的 VWAP,不是每日均價的平均。window=1 時也可能與 hub 當日的 buy_avg_price 欄位不同(hub 的日均價是它自己算的,這裡是用當日金額/股數重算)。買進股數為 0 時分母為 0 → NULLIF → 該欄為 null。
  • 股數欄位(buy_shares/sell_shares/net_shares)是整數字串;金額欄位帶 NUMERIC(38,8) 的完整精度;均價是兩個 NUMERIC 相除的結果,精度是 NUMERIC 相除的精度(依數值大小 12–16 位小數不等);請用 toNumberOrNull 轉數字。

Error:

情境 HTTP
未帶/無效 token 422 / 401
window 未帶或不是 1/5/20/60 422
ticker 解析失敗(含裸碼無 .TW/.TWO)或非台股(AAPL) 422
ticker 不在 taiwan.stocks 404
ticker 存在但窗口內無列 200,items: [](見上;start_date/end_date 仍是 dataset 窗口)

Response(完整範例,實際 API 輸出):

{
  "ticker": "2330.TW",
  "window": 5,
  "start_date": "1902-01-06",
  "end_date": "1902-01-07",
  "actual_days": 2,
  "items": [
    {
      "participant_id": "9A00-9A01",
      "participant_name": "永豐金-台北",
      "buy_shares": "2400000",
      "sell_shares": "800000",
      "net_shares": "1600000",
      "buy_value": "144000000.00000000",
      "sell_value": "47200000.00000000",
      "net_value": "96800000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "59.0000000000000000"
    },
    {
      "participant_id": "1234-5678",
      "participant_name": "元大-土城",
      "buy_shares": "600000",
      "sell_shares": "1800000",
      "net_shares": "-1200000",
      "buy_value": "36300000.00000000",
      "sell_value": "107100000.00000000",
      "net_value": "-70800000.00000000",
      "buy_avg_price": "60.5000000000000000",
      "sell_avg_price": "59.5000000000000000"
    }
  ]
}

日期是驗證環境的合成資料(1902 年),正式環境為真實交易日。participant_name 取窗口內最新一天的名稱(分點改名時以新名為準)。同 §4b,本 endpoint 的金額欄位也可能出現 "0E-8"(NUMERIC(38,8) 的零值字串表示),意義與處理方式相同。


4b. Endpoint 四(新):GET /api/participant-flows/broker-branch/branch/{participant_id}(分點 × 窗口 × 全部個股)

同一個窗口規則,方向相反:給一個分點,回它在窗口內買賣過的所有個股。

Path Type Notes
participant_id string 分點代號,如 9A00-9A01;長度上限 80(DB VARCHAR(80)),超過回 422
Query Type Required Default Notes
window integer yes (無) 同 §4:1/5/20/60
limit integer no 5000 1..5000;全市場約 2,001 檔個股,一頁即可取全量;截斷發生在 ORDER BY net_value DESC NULLS LAST 之後,limit 設小時只會留下淨買超最多的前幾名
  • items 每檔個股一列,GROUP BY stock_code,排序 ORDER BY net_value DESC NULLS LAST, stock_code。
  • name/listing_type 來自 LEFT JOIN taiwan.stocks:hub 帶有未收錄進 cortex 個股主檔的 ETF/權證代碼,這些列的 name/listing_type 都是 null,ticker 則是裸代碼(例如 "00940");有主檔的個股 ticker 為 2330.TW/6488.TWO。
  • 加總、VWAP、null 處理與排序規則與 §4 完全相同;start_date/end_date/actual_days 語意亦同,無交易日時分別為 null、null、0,web 型別需為 string | null。

Error:

情境 HTTP
未帶/無效 token 422 / 401
window 未帶或不是 1/5/20/60 422
participant_id 超過 80 字元 422
該分點在窗口內沒有任何列 404

A 與 B 的不對稱(空窗口:A 回 200 + [],B 回 404):個股有 taiwan.stocks 這張主檔可以判斷「這檔股票存在但今天沒資料」,所以 A 用 404 表示「ticker 不存在」、用 200 + items: [] 表示「存在但窗口內無資料」。分點沒有主檔(cortex 沒有分點名冊表),唯一能判斷「這個分點存不存在」的訊號就是「窗口內有沒有列」,因此 B 只能把兩種情況合併成 404。前端在 B 收到 404 時應顯示「查無此分點或該分點近 N 日無交易」,不要當成錯誤彈窗。

Response(完整範例,實際 API 輸出):

{
  "participant_id": "9A00-9A01",
  "participant_name": "永豐金-台北",
  "window": 5,
  "start_date": "1902-01-06",
  "end_date": "1902-01-07",
  "actual_days": 2,
  "items": [
    {
      "stock_code": "2330",
      "ticker": "2330.TW",
      "name": "台積電",
      "listing_type": "tw",
      "buy_shares": "2400000",
      "sell_shares": "800000",
      "net_shares": "1600000",
      "buy_value": "144000000.00000000",
      "sell_value": "47200000.00000000",
      "net_value": "96800000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "59.0000000000000000"
    },
    {
      "stock_code": "6488",
      "ticker": "6488.TWO",
      "name": "環球晶",
      "listing_type": "two",
      "buy_shares": "100000",
      "sell_shares": "20000",
      "net_shares": "80000",
      "buy_value": "19000000.00000000",
      "sell_value": "3760000.00000000",
      "net_value": "15240000.00000000",
      "buy_avg_price": "190.0000000000000000",
      "sell_avg_price": "188.0000000000000000"
    },
    {
      "stock_code": "00940",
      "ticker": "00940",
      "name": null,
      "listing_type": null,
      "buy_shares": "200000",
      "sell_shares": "0",
      "net_shares": "200000",
      "buy_value": "1960000.00000000",
      "sell_value": "0E-8",
      "net_value": "1960000.00000000",
      "buy_avg_price": "9.8000000000000000",
      "sell_avg_price": null
    }
  ]
}

第三列是 hub-only 的 ETF 代碼:name/listing_type 為 null、ticker 為裸碼。"0E-8" 是 PostgreSQL NUMERIC(38,8) 的零值字串表示(Number("0E-8") === 0,toNumberOrNull 可正確處理)。


4c. Endpoint 五(新):GET /api/participant-flows/broker-branch/stock/{ticker}/branch/{participant_id}(個股 × 分點 × 逐日)

給定一檔個股 + 一個分點,回窗口內逐日明細(不像 §4/§4b 先彙總成一列)。這是分點個股卡「展開看逐日明細」的資料來源,追蹤於 rdaoshiken/Cortex-web#287。

Path Params:

Path Type Notes
ticker string 同 §2/§4:外部格式 2330.TW/2330.TWO;裸碼或非台股回 422
participant_id string 分點代號,如 9A00-9A01;長度上限 80(DB VARCHAR(80)),超過回 422

Query Params:

Query Type Required Default Notes
days integer yes (無) 1..116(MAX_BROKER_BRANCH_DAILY_DAYS,由保留期天數導出);超出範圍回 422。輸入驗證請用這個範圍,實際拿得到的天數見下方說明

⚠️ 同一個 router 裡有兩種 days,不要混用:

  • government-bank/stock(§2)的 days:選填、預設 30、以今日(Asia/Taipei)往回抓的日曆天數。
  • 本 endpoint的 days:必填、無預設值、是整個券商分點 dataset 最近 N 個 distinct trade_date(交易日筆數),與 §4/§4b 的 window 同一種窗口建構方式,只是不限 1/5/20/60,1..116 之間可帶任意整數。串接時務必用對,兩者名稱一樣但語意完全不同(前者上限 3650 日曆天、後者上限 116 交易日)。

保留期會讓實際回傳天數少於 days,但驗證請照 schema 上限:days 的 schema 範圍是 1..116(MAX_BROKER_BRANCH_DAILY_DAYS,由保留期導出)。券商分點列每日由排程 purge 掉超過 BROKER_BRANCH_RETENTION_DAYS(116 個日曆天)的資料(hub_participant_flows_sync.py 的 purge_expired),所以 116 個日曆天內大約只有 84 個交易日,actual_days 通常會小於 days、start_date 不會早於約 116 天前。

  • 輸入驗證請用 1..116,不要自行收到約 84:116 個日曆天可以裝下超過 84 個交易日,端點也刻意接受 days=85..116(實測 days=116 回 200;schema 上限內的其他值同樣合法)。把 UI 上限訂在 84 會擋掉使用者查得到的合法區間。
  • 超過 116 是 422,不是截斷:days=117/days=120 直接回 422 參數錯誤,不會回一份被截短的資料。
  • 「約 84 個交易日」請當成說明文案(告訴使用者實際大概拿得到多久),不要當成驗證上限。

逐日與 null 語意(前端渲染的關鍵):

  • 窗口內每個交易日都會有一個 item,依 trade_date 新到舊排序;不會因為某天沒交易就跳過那天。
  • 該分點當天沒有交易此股時,八個數量欄位(buy_shares/sell_shares/net_shares/buy_value/sell_value/net_value/buy_avg_price/sell_avg_price)一律回 null。判斷「當天有無交易」請看 buy_shares === null——底層欄位是 BIGINT NOT NULL,真實列的 buy_shares 必為非 null,這是唯一可靠的判斷式;response 裡刻意沒有額外的 traded boolean。
  • unpriced day:當天股數非零但金額不可得時,只有那一側的 *_value/*_avg_price 回 null,*_shares 仍保留真實股數;net_value 也會因為其中一側金額為 null 而一併變成 null(SQL NULL 隨算式傳遞),net_shares 只要雙邊股數都在就仍是實際差額,不受影響。買/賣兩側各自獨立判斷,不會互相拖累。
  • buy_avg_price/sell_avg_price 公式是 value / NULLIF(shares, 0),與 §4/§4b 窗口 VWAP 用的公式相同,但這裡是逐日各自計算、不先加總——用意是讓這支的逐日數字能跟上層卡片(§4/§4b)的窗口彙總對得起來。
  • 無 limit 參數;列數自然是 min(days, 窗口內可得交易日數) = actual_days。

Response 欄位:

欄位 型別 說明
ticker string 外部格式,回聲 path
participant_id string 回聲 path
participant_name string 窗口內最新一天的分點名稱
days integer 回聲請求的 days
start_date / end_date string(date) dataset 窗口的最早/最晚交易日;回 200 時一定非 null(見下方 404 說明)
actual_days integer 窗口實際涵蓋的交易日數,等於 items.length
items array 每個窗口交易日一列

items[] 每列:

欄位 型別 說明
trade_date string(date)
buy_shares / sell_shares / net_shares string | null 整數字串;當天未交易時為 null
buy_value / sell_value / net_value string | null NUMERIC(38,8) 字串;當天未交易或該側 unpriced 時為 null
buy_avg_price / sell_avg_price string | null 單日 value/NULLIF(shares,0);當天未交易或該側 unpriced 時為 null

Web 端建議型別(TypeScript,供對照上面兩張表;web 尚未建立):

interface BrokerBranchDailyItem<TNumeric = string> {
  trade_date: string;
  buy_shares: TNumeric | null;
  sell_shares: TNumeric | null;
  net_shares: TNumeric | null;
  buy_value: TNumeric | null;
  sell_value: TNumeric | null;
  net_value: TNumeric | null;
  buy_avg_price: TNumeric | null;
  sell_avg_price: TNumeric | null;
}

interface BrokerBranchStockDailyResponse<TNumeric = string> {
  ticker: string;
  participant_id: string;
  participant_name: string;
  days: number;
  // 與 §4/§4b 的型別不同:C 回 200 時保證至少一個 item,
  // 因此 start_date/end_date 不像 A/B 需要 `| null`(空窗口一律走 404)。
  start_date: string;
  end_date: string;
  actual_days: number;
  items: BrokerBranchDailyItem<TNumeric>[];
}

TNumeric 同 §1 假設 #7 對 GovernmentBankStockResponse<TNumeric> 的用法:wire 上一律是 string,經 toNumberOrNull(見 §5)轉換後 hook 回傳 TNumeric = number。

Error:

情境 HTTP
未帶/無效 token 422 / 401
days 未帶、非正整數或超出 schema 上限(見 OpenAPI/API_SPEC) 422
ticker 解析失敗(含裸碼無 .TW/.TWO)或非台股(AAPL) 422
ticker 不在 taiwan.stocks 404
participant_id 超過 80 字元 422
該分點在窗口內查無此股任一列 404

404 比 B(§4b)更窄:B 只要「這個分點在窗口內完全沒有任何列」就 404(分點沒有名冊主檔,「有無列」是唯一存在性訊號);本 endpoint 是 ticker + 分點兩個維度同時要對得上才有資料——分點存在、但沒交易過這檔股票,或這檔股票存在、但這個分點沒交易過它,兩種情況都回 404。前端在收到 404 時可沿用 §4b 的處理方式(顯示「查無資料」而非錯誤彈窗),但訊息文案建議同時涵蓋「查無此股」與「該分點近 N 日未交易此股」兩種可能,因為單看 404 無法從 status code 分辨是哪一種。

Response(範例一:happy path,窗口內每天都有成交):

{
  "ticker": "2330.TW",
  "participant_id": "9A00-9A01",
  "participant_name": "永豐金-台北",
  "days": 2,
  "start_date": "2026-09-08",
  "end_date": "2026-09-09",
  "actual_days": 2,
  "items": [
    {
      "trade_date": "2026-09-09",
      "buy_shares": "1200000",
      "sell_shares": "300000",
      "net_shares": "900000",
      "buy_value": "72000000.00000000",
      "sell_value": "18150000.00000000",
      "net_value": "53850000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "60.5000000000000000"
    },
    {
      "trade_date": "2026-09-08",
      "buy_shares": "800000",
      "sell_shares": "950000",
      "net_shares": "-150000",
      "buy_value": "48400000.00000000",
      "sell_value": "58900000.00000000",
      "net_value": "-10500000.00000000",
      "buy_avg_price": "60.5000000000000000",
      "sell_avg_price": "62.0000000000000000"
    }
  ]
}

Response(範例二:混合情境,含未交易日與 unpriced day):

{
  "ticker": "2330.TW",
  "participant_id": "1480",
  "participant_name": "美商高盛",
  "days": 5,
  "start_date": "2026-09-03",
  "end_date": "2026-09-09",
  "actual_days": 5,
  "items": [
    {
      "trade_date": "2026-09-09",
      "buy_shares": "1200000",
      "sell_shares": "300000",
      "net_shares": "900000",
      "buy_value": "72000000.00000000",
      "sell_value": "18150000.00000000",
      "net_value": "53850000.00000000",
      "buy_avg_price": "60.0000000000000000",
      "sell_avg_price": "60.5000000000000000"
    },
    {
      "trade_date": "2026-09-08",
      "buy_shares": null,
      "sell_shares": null,
      "net_shares": null,
      "buy_value": null,
      "sell_value": null,
      "net_value": null,
      "buy_avg_price": null,
      "sell_avg_price": null
    },
    {
      "trade_date": "2026-09-07",
      "buy_shares": "800000",
      "sell_shares": "950000",
      "net_shares": "-150000",
      "buy_value": "48400000.00000000",
      "sell_value": "58900000.00000000",
      "net_value": "-10500000.00000000",
      "buy_avg_price": "60.5000000000000000",
      "sell_avg_price": "62.0000000000000000"
    },
    {
      "trade_date": "2026-09-04",
      "buy_shares": "500000",
      "sell_shares": "200000",
      "net_shares": "300000",
      "buy_value": null,
      "sell_value": "12000000.00000000",
      "net_value": null,
      "buy_avg_price": null,
      "sell_avg_price": "60.0000000000000000"
    },
    {
      "trade_date": "2026-09-03",
      "buy_shares": "600000",
      "sell_shares": "600000",
      "net_shares": "0",
      "buy_value": "36600000.00000000",
      "sell_value": "36300000.00000000",
      "net_value": "300000.00000000",
      "buy_avg_price": "61.0000000000000000",
      "sell_avg_price": "60.5000000000000000"
    }
  ]
}

2026-09-08 是「當天沒有交易此股」的示例(八個數量欄位皆 null,buy_shares === null 是判斷依據);2026-09-04 是「unpriced day」示例(buy_shares 非 null 但當日買方金額不可得,buy_value/buy_avg_price/net_value 隨之為 null,sell 側不受影響)。完整 endpoint 定義見 docs/API_SPEC.md 與 docs/api-reference.md 同一 endpoint。

Response(範例三:404 — 窗口內查無此股 × 分點的任何列,同時涵蓋「no-data」與「error」兩種情境):

{
  "error": {
    "code": "No broker-branch flows for participant 9A00-9A01 on ticker 2330.TW in the last 5 trade dates",
    "message": "No broker-branch flows for participant 9A00-9A01 on ticker 2330.TW in the last 5 trade dates",
    "details": {}
  }
}

這是 FastAPI HTTPException(detail=<字串>) 經全域 handler 重塑後的 envelope 形狀(docs/api-reference.md §通用 Response 格式「一、端點自己拒絕」),code/message 皆等於後端拋出的 detail 字串。C 沒有 A 的「200 + items: []」狀態,該分點沒交易過這檔股票、與這檔股票沒被這個分點交易過,兩種情況都收斂成同一個 404,訊息文案不可只覆蓋其中一種(見上方「404 比 B 更窄」)。

Web 串接檢查清單:

  • [ ] days 必填:不要套用 §2 的 30 天預設,也不要套用 §4/§4b 的 1/5/20/60 限制
  • [ ] React Query 的 queryKey 需同時帶 ticker/participant_id/days,否則不同分點或不同天數會共用快取(比照 §5 對 window 的要求)
  • [ ] 用 item.buy_shares === null 判斷「當天有無交易」,不要另外猜測或硬編 traded 欄位
  • [ ] unpriced day:buy_value/buy_avg_price(或 sell 側)為 null 不代表當天沒交易,*_shares 仍是真實股數;UI 需能分開呈現「無交易」與「有交易但金額不可得」兩種空值
  • [ ] 收到 404 時比照 §4b 顯示「查無資料」文案(同時涵蓋「查無此股」與「該分點近 N 日未交易此股」),不要當成錯誤彈窗
  • [ ] 數值欄位一律走 toNumberOrNull,不可直接 Number()(null 會被轉成 0)
  • [ ] days 的輸入驗證用 schema 範圍 1..116(超過回 422,不是截斷);「實際大約只拿得到 84 個交易日」放在說明文案,不要拿來當驗證上限

5. hook 層轉換(web 端既有實作,供對照)

toNumberOrNull(src/lib/api/hooks/use-participant-flows.ts)把上述字串轉成 number,null 維持 null(Number(null) 會是 0,故不可直接用 Number())。既有三個 hook:useGovernmentBankStock、useGovernmentBankRanking、useBrokerBranchStock,皆以 useAuthStore((s) => s.isAuthenticated()) 閘門,未登入不觸發請求。

本次改版對 hook 層的影響:

  • useBrokerBranchStock(ticker, window) 必須把 window 一起放進 queryKey,否則 1/5/20/60 四個窗口會共用同一份快取。
  • 回應型別:BrokerBranchStockResponse 移除 trade_date/buy/sell,改為 window/start_date/end_date/actual_days/items;item 型別新增 buy_shares/sell_shares/buy_value/sell_value/sell_avg_price。
  • 不需要再帶 limit(預設 5000 已是全量),PARTICIPANT_BROKER_TABLE_LIMIT 可移除,改由前端分頁。
  • 分點 endpoint(§4b)本輪 web 不做 UI,型別可先不建。

6. Out of scope(本次不含)

  • 分點市場排行 tab(沒有跨分點的排行 endpoint)
  • 分點清單 endpoint(/broker-branch/branches)與分點頁 UI:§4b 的 endpoint 已經可用,但本輪 web 只做個股卡
  • /chip 八大行庫 ranking 加收盤價欄(C-D ranking 回應無 close price;follow-up)
  • 分點資料 116 天以外的歷史查詢(保留期限制,非 bug)——§4/§4b 的 window 仍只支援 1/5/20/60;§4c 的 days 雖已開放自訂天數(1..116),但實際仍受同一個 116 天保留期限制,查不到 116 個日曆天以外的資料,且帶 117 以上會直接回 422