MarketDaily ← 所有文章
MARKETDAILY · 工程

資料隔離事故報告:公版觀察清單怎麼混進「你的持股」

📬 喜歡這類拆解?MarketDaily 每天早上 7 點把美股 + 台股 AI 過濾日報寄到你信箱。免費訂閱 →

前兩篇講了 council + judge 的多模型仲裁,以及確定性稽核層的 30 項檢查怎麼分類。這篇只拆其中一項 portfolio_lens_foreign_ticker,因為它同時是一次真實的資料隔離事故一次防線自己造成的事故,以及一個到今天還沒過期的設計教訓

先坦白:這道防線的白名單,比程式註解說的多了 27%

動筆第一件事是重跑這篇要用的每個數字。第一個就對不上。程式裡那段註解寫著:

# 改成「必須是真實上市證券」才算外來:台股比對 .tw_names_cache.json(12k 代號)

實際數:

python3 -c "import json;print(len(json.load(open('scripts/.tw_names_cache.json'))))"
# → 15273

當初寫下那行時的紀錄是 12,019 筆,所以註解在寫的當下是對的,只是那份名表會隨上市櫃新增而長,兩個月長了 27%。

我第一版稿子在這裡寫了一段很順的結論:「白名單防線的正確性有保存期限」。然後照規矩去查證,發現是錯的——那份名表每小時會自己從 TWSE 與 TPEx 重抓一次,不是死的快照。差一點就把一個沒查證的漂亮說法寫進文章裡,而這個系列的紅線第一條就是數字要先查。

真正成立的版本比較小、但比較有意思,留到最後一節講。以下的數字都以今天實測為準。

第一幕:個人化系統最尷尬的失敗

2026-07-21,日報裡一個叫「組合透視」的區塊出事了。

這個區塊的標題掛著「你的」兩個字,內容是針對訂閱者自己選的持股做市場配置與估值分佈。那天它混進了公版預設觀察清單的 10 檔美股:

# analyzer.py
default_us = ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA", "TSLA", "AMD", "TSM", "JPM"]
watchlist_us = user_us_stocks if user_us_stocks else default_us

症狀是「市場配置」憑空多出十檔美股,而「DCF 偏貴」那一列點名的標的沒有一支是用戶持有的

值得停下來想一秒的是這次外洩的方向。個人化系統出資料隔離問題,直覺會想到「A 用戶的資料跑到 B 用戶的信裡」——那是嚴重但好解釋的錯。這次是反過來:公版資料洩進了個人視角。沒有任何一個訂閱者的隱私受損,一個 byte 都沒有跨用戶流動,但收信的人打開「你的組合」,看到十檔他從沒買過的股票。

技術上損失是零。信任上損失是全部。個人化產品賣的就是「這封信懂我」,而這個區塊當場證明它不懂——比起一個明顯的 bug,這種「看起來很專業但其實在講別人的事」更傷,因為用戶沒辦法分辨這封信裡還有哪些段落也是假的個人化。

修法三層

  1. 鐵則化(規格層):任何標題掛「你的」的區塊,其標的必須完全來自傳入的真實持股清單。公版精選場景(用戶沒選該市場的股票,系統改送 AI 委員會精選)holdings 就等於 picks 清單,同樣受約束。沒有持股的純公版報告不檢查——它從來沒宣稱是個人化的。
  2. 檢查層:digest_audit.py 新增 portfolio_lens_foreign_ticker,severity HIGH。在這套系統裡 HIGH 的語義很重:觸發完整的 retry 鏈(等 60 秒 → 換更強的模型整封重生 → 仍失敗就切純程式備援版)。
  3. 退路層:備援版不准偷偷用通用內容冒充個人化版寄出去。降級可以,假裝沒降級不行。

第 3 層看起來像贅述,但它是後面第二幕的伏筆。

第二幕:防線自己把用戶的信變成了閹割版

三天後,2026-07-24 的台股早報,我自己收到的是這個:

⚠️ 今天 AI 個人化生成異常,這封是備援版本

而當天的 audit JSON 寫著 personalization_failed_count: 0。報告全綠,信是閹割版。

根因 A:檢查的 token 抽取太粗。 初版是這樣找「文字裡出現的證券代號」的:

found  = set(re.findall(r"\b[A-Z]{2,5}\b", lens_text))
found |= set(re.findall(r"\b\d{4,6}\b", lens_text))

這兩條正則在一篇財經敘述裡會撈到什麼:年份(2026)、指數點位(23150)、價位(1085)、名詞縮寫(AI / GDP / CPI / ETF)。它們全部被判定成「你沒持有的外來標的」→ HIGH → retry。換更強的模型重生一版文字正常的內容,結果踩到別的數字,又是同一個 HIGH → 切備援。

這個誤判有個殘酷的分佈:敘述越豐富的用戶越容易中。老手級的個人化內容數字最多,台股班次又天天有指數點位,所以幾乎每個台股班次必中。也就是說,這道防線最先懲罰的是系統對其最用力的那群人。

根因 B:報告看不見這件事。 當時 audit JSON 只寫三類欄位:personalization_failed / audit_failed / by_email,完全沒有 deterministic_fallback。而備援版是純程式產生的,拿去重新稽核當然零失分、不會進 by_email,寫檔條件也不含 fallback。於是形成一個完美的盲區:掉備援這件事在報告裡沒有任何欄位可以承載,只有收信的人看得到。

這是上一篇講的「檢查自己引發事故」的第二個實例,而且比第一個更難發現——第一個(undefined_css_class 全員降級)至少在報告裡是紅的。

真正的修法:把啟發式換成真實 universe

誘人的修法是「把誤判的樣式一個個排除掉」:年份不算、四位數以 20 開頭的不算、全大寫三字以內的常見縮寫建個黑名單……這條路的問題是它永遠列不完,而且每漏一個就是一次用戶收到閹割版。

實際的修法是把問題反過來問。原本問的是「這個 token 看起來像不像代號」,改成問「這個 token 是不是一支真實存在的上市證券」:

def _is_real_ticker(tok):
    """token 是否為真實上市證券代號(排除年份/指數/價位/名詞縮寫等非證券數字)。"""
    t = (tok or "").strip().upper()
    if not t:
        return False
    if t.isdigit():
        return t in _tw_ticker_universe() or t in stock_names.TW_NAMES
    return stock_names.is_known(t)

台股比對本地代號快取(今天 15,273 筆),美股比對名稱庫。效果:

同一輪把根因 B 也補上:audit JSON 加 deterministic_fallback_count 與名單,寫檔條件納入三類失敗(掉備援 / 個人化拋錯 / audit 失分)。掉備援從此不再隱形。

驗證用 5 組固定案例凍住:誤判樣本不再被殺、AAPL/6488 真洩漏仍被抓、乾淨內容不誤報,加上 _is_real_ticker 的單元測試。

導出的設計準則

一、個人化區塊的隔離要靠白名單契約,不是靠偵測。 「找出不該出現的東西」需要枚舉所有壞情況;「確認每個出現的東西都在允許清單裡」只需要一份清單。前者的漏網是無聲的,後者的漏網會當場紅。

二、白名單的例外判準要用真實 universe,不要用啟發式。 「看起來像代號」是啟發式,會同時產生假陽性與假陰性;「在 15,273 筆上市代號表裡」是事實查詢,兩種錯都消失。代價是你得維護那份表——這正是第三條。

三、防線與它的白名單之間,有一條沒人在看的供給線。 這條是查證過程中長出來的,原本大綱裡沒有。

名表本身是活的:data_fetcher.tw_name_map() 每小時重抓 TWSE + TPEx,抓到就覆寫磁碟,抓不到就沿用上次成功的版本(「官方源斷線時名字永不歸零」是它刻意的設計,日報才不會出現裸代號)。

但稽核這一側不走那條函式digest_audit._tw_ticker_universe() 是直接開磁碟上那個 json:

with open(p, encoding="utf-8") as f:
    uni = set(json.load(f).keys())

也就是說,稽核用的 universe 新不新鮮,完全取決於別人(日報生成流程)有沒有跑過、跑成功沒有。它自己不會去刷新,也沒有任何東西在量這個檔多舊了——我找過了,全庫沒有一處檢查它的 mtime。

正常日子這沒差,兩邊在同一個流程裡跑。但把失敗模式想完:上游連續抓失敗(2026-07-09 就發生過一次,winrig 的 certifi 過期害 TPEx SSL 全滅),磁碟名表就凍在那一刻。日報那側有明確的降級語義(沿用舊名字,不歸零);稽核那側沒有——它會安靜地拿一份舊 universe 去判「這是不是真實證券」,而新上市的標的在舊表裡查不到 ⇒ 判定非證券 ⇒ 放行。防線會在它最該作用的地方(最新、最容易被公版清單帶進來的標的)先瞎掉,而且不會有人知道。

這不是今天的事故,是一條還沒射出來的引信。寫這篇之前沒人量過它。

所以這篇沒有停在「值得注意」——引信當場拆掉了,新增檢查 ticker_universe_stale(MED):

age = _tw_universe_age_days()
if age is None:
    ...  # 檔不見了 = 台股那半形同關閉
elif age > TICKER_UNIVERSE_STALE_DAYS:      # 7 天
    ...  # 「新上市標的查不到會被判非證券而放行,這道防線正在半瞎」

幾個刻意的選擇:量的是檔案年齡不是筆數(筆數變多變少都不代表新鮮);severity 是 MED 不是 HIGH(白名單舊了不該讓用戶收到閹割版——那正是第二幕的錯誤);只在真的用到白名單的個人化報告檢查,純公版報告不製造噪音;訊息寫的是後果(「會被判非證券而放行」)不是症狀(「檔案舊了」),因為半年後看到這則告警的人需要知道的是損失什麼,不是哪個檔案的 mtime 比較小。

迴歸測試 7 條,最後一條是突變對照:把門檻改成天文數字,前面那條斷言必須當場變綠——不會因為突變而變綠的測試,等於沒有在量門檻。

查證數字本身就是稽核,這已經是這個系列第二次在動筆時抓到自己的東西。

查證指令

# 白名單實際筆數
python3 -c "import json;print(len(json.load(open('scripts/.tw_names_cache.json'))))"

# 這道檢查的實作與 severity
grep -n "portfolio_lens_foreign_ticker" digest_audit.py

# 公版預設觀察清單(事故洩漏源)
grep -n "default_us = " analyzer.py

# 誤判根治後的判準
sed -n '/def _is_real_ticker/,/is_known/p' digest_audit.py

# 白名單多舊了(稽核側沒有任何東西在量這個)
stat -c '%y  %n' scripts/.tw_names_cache.json

MarketDaily 每天早晚用這套系統寄出日報,目前全功能限時免費——現在訂閱的早鳥用戶,未來恢復收費後永久保留免費使用權

本文僅供資訊整理,非投資建議。投資有風險,請評估自身狀況。資料更新:2026-08-12