八大行庫/券商分點 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 JOINtaiwan.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"是 PostgreSQLNUMERIC(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 個 distincttrade_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 裡刻意沒有額外的tradedboolean。 - 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