跳轉到

Runbook:永豐 ETF 持股資料日期修復(issue #1435)

本流程修復舊版 SinopacProvider 將 scheduler 查詢日誤寫成持股 as_of_date 的資料。永豐 PCF 頁面的「資料日期」是唯一日期依據;不得固定 減一天,也不得使用 FinMind 代替。

0. 前置條件

  • issue #1435 的 parser 修正已合併並部署。
  • country-schema cutover 已完成,以下 tables 必須存在:
  • taiwan.etf_funds
  • taiwan.etf_holdings
  • taiwan.etf_holdings_sync
  • archive_taiwan schema
  • 執行環境可讀永豐公開 PCF 頁面。
  • 正式 apply 前必須停止會寫入 ETF holdings 的 scheduler,避免 dry-run、備份 與 apply 之間出現新寫入。

若環境仍只有 public.etf_*,停止本流程,先完成 country-schema cutover; 不得臨時把修復腳本改回舊 schema。

1. 首次 dry-run 與 scope capture(唯讀)

在已部署的新 backend image 執行:

python -m scripts.repair_sinopac_holding_dates \
  --database-url "$DATABASE_URL" \
  --capture-live-scope \
  --report-path /app/logs/sinopac-date-repair-dry-run.json

--capture-live-scope 是首次稽核必要的明確動作:腳本只在這次從 DB 擷取 scope,並把 scope 與 dry-run 結果寫入指定報告。--report-path 必須是 本次新輸出的檔案;腳本會在任何 provider/DB 操作前以 exclusive create 預留該路徑,若檔案已存在就中止。後續 apply、驗證與 replay 不得覆寫這份 原始報告。

scope 的語意:

  • scope_source 應為 live-db-capture;首次輸出中的 scope_report_sha256 為 null,因為報告不能包含自身完整檔案的 hash。 核准時另行記錄原始報告檔案的 SHA-256;後續輸出會記錄這個輸入檔案的 hash。
  • scope 是 provider=sinopac 的 ETF 及其當時已儲存的 query dates;來源 as_of_date 不得在這次 capture 後被重新當成 query date。
  • --etf、--start-date、--end-date 只可縮小 live scope;它們不是新的 scope 身分,也不能把部分報告宣稱為全量稽核。輸出報告的 filters 會完整 記錄這三種參數;正式全量 capture 時 etfs=[] 且兩個日期都應為 null。
  • 對 captured scope 中的每個 query date 逐檔逐日查詢永豐歷史頁面。
  • 解析每次回應的真實「資料日期」及完整持股。
  • 計算 query date → source date 映射及預計替換日期。
  • 完全不寫 DB。

若無法取得完整 scope、scope provenance 不明、報告遺失或 hash 無法重算, 必須 fail closed;不得自行從目前 live DB 重新發現日期來繼續。

2. Dry-run 審查 gate

報告必須符合以下條件才能提出 apply 核准:

  • safe_to_apply=true
  • unresolved=[]
  • affected_etfs 與 DB 中所有永豐 ETF 清單一致
  • scope_source=live-db-capture、scope_report_sha256=null,並另行記錄原始 報告檔案可重算的 SHA-256
  • filters={"etfs":[],"start_date":null,"end_date":null};若不是,該報告 只能代表明示的縮小範圍,不得核准為全量修復
  • query_snapshot_count 與 captured scope 的 query-date 集合一致
  • 每個 query_to_source 映射都有來源日期與前後 row count
  • holding_dates_to_replace、sync_dates_to_replace 的範圍合理
  • 已記錄 plan_sha256

以下任一情況會阻擋 apply:

  • 永豐頁面無法取得或資料日期缺失/無效/歧義
  • 回應資料日期晚於 query date
  • 同一 source date 的多個回應內容衝突
  • 來源頁面解析為空,但 DB 的同一 source date 已有持股
  • DB 在 dry-run 與 apply 之間發生 drift

將完整報告、精確環境/database、預計影響筆數、備份 label 與 rollback SQL 提交使用者審查。未收到明確核准不得執行下一節。

3. 正式 apply(只使用原始 scope)

先停止 scheduler,確認沒有其他 ETF holdings writer,再使用剛核准報告中的 hash:

python -m scripts.repair_sinopac_holding_dates \
  --database-url "$DATABASE_URL" \
  --apply \
  --scope-report /app/logs/sinopac-date-repair-dry-run.json \
  --expected-plan-sha256 "<DRY_RUN_PLAN_SHA256>" \
  --backup-label "sinopac_yyyymmdd_01" \
  --report-path /app/logs/sinopac-date-repair-applied.json

--scope-report 必須指向原始 dry-run JSON,--report-path 必須是不同的 apply 輸出檔。apply 不得帶 --capture-live-scope,也不得從 live DB rediscover scope;它只驗證原始 scope 報告、重新抓取該 scope 的來源並重算 plan_sha256。腳本會把原始 scope 報告的檔案 SHA-256 記錄在 apply 輸出的 scope_report_sha256,供操作者與已核准的原始報告 checksum 核對;計畫 hash 不符則直接中止。

開始改資料前,腳本在 archive_taiwan 建立三個不可覆寫的備份表:

  • sinopac_funds_<label>
  • sinopac_holdings_<label>
  • sinopac_sync_<label>

備份 commit 完成後才開始第二個 transaction。該 transaction 會:

  1. 重新 lock/read 受影響 rows 並再次比對 plan hash。
  2. 將各 source date 替換成永豐當次歷史回應的完整快照。
  3. query-only 日期移除錯標 holdings,sync 改為 retryable empty。
  4. source date sync 記為 success 與正確 fetched count。
  5. transaction 內重新讀回;任何 holdings/sync 差異都 rollback。

4. 修復後驗證與 replay(只使用原始 scope)

驗證必須繼續傳入同一份原始 scope,並輸出到另一個新檔案;不得省略 --scope-report,也不得用目前 live DB 重新發現 scope:

python -m scripts.repair_sinopac_holding_dates \
  --database-url "$DATABASE_URL" \
  --scope-report /app/logs/sinopac-date-repair-dry-run.json \
  --report-path /app/logs/sinopac-date-repair-verify.json

需要重播或調查時,仍使用同一 --scope-report,另指定不同的輸出報告;禁止 使用 --capture-live-scope。已完整修正時:

  • holding_dates_to_replace=[]
  • sync_dates_to_replace=[]
  • unresolved=[]

若原始 scope 報告或核准時記錄的檔案 SHA-256 無法復原,停止流程並保留現有 資料與備份;不得以 live DB 的當前日期集合補建 scope。只有找回同一份原始 報告並核對 checksum 後,才可繼續驗證或 replay。

再執行唯讀 DB 與 API 驗證:

SELECT f.etf_stock_code, h.as_of_date, COUNT(*) AS holding_rows
FROM taiwan.etf_funds AS f
JOIN taiwan.etf_holdings AS h USING (etf_stock_code)
WHERE f.provider = 'sinopac'
GROUP BY f.etf_stock_code, h.as_of_date
ORDER BY f.etf_stock_code, h.as_of_date;

SELECT etf_stock_code, as_of_date, status, fetched_count, error_message
FROM taiwan.etf_holdings_sync
WHERE provider = 'sinopac'
ORDER BY etf_stock_code, as_of_date;

確認主動式 ETF 買賣排行不再選到尚未開盤的錯誤日期,才可重新啟動 scheduler。

5. Rollback

apply 報告的 backup.rollback_sql 是該次 backup label 的精確 rollback。若 修復後驗證失敗,保持 scheduler 停止,核對 table 名與 row counts 後執行該 SQL。Rollback 會刪除備份範圍內目前的永豐 holdings/sync,再從 archive_taiwan 還原原始 rows。

Rollback 後重新執行 DB/API 驗證,並保留三個 archive tables 與兩份 JSON 報告供調查;不得在未驗證前刪除備份。