ETF 持股來源日與錯置資料修復
日期契約
as_of_date 是官方明示的持股資料日。查詢日、抓取日、公告日、上傳時間不能取代它。
| Provider | 正式查詢與持股日期 | 原有資產解析 |
|---|---|---|
| 元大 | PCF 頁支援 navDate=YYYY-MM-DD;以回覆 PCF.trandate 為準 |
保留 Nuxt IIFE 的股票、債券、ETF 陣列及既有期貨排除 |
| 兆豐 | 商品頁持股區 #asset_div 的「資料來源:兆豐投信,YYYY/MM/DD」 |
保留六類資產;商品頁可能只提供目前來源日 |
| 中信 | ETFHoldingWeight 送出 StartDate;以同一基金資產及持股明細區的「資料日期」為準,並核對 NAV_DT |
保留原有資產與期貨契約年月鍵 |
| 統一 | GetPCF 送出公告日 date;以每列 Details.TranDate 為準(ISO 或 /Date(毫秒)/,毫秒依台北時區換算),並核對 fund.sFundCode、fund.sStockNo、每列 FundCode |
保留 AssetCode 分類與期貨契約年月鍵 |
統一投信的公告日與資料日不是固定差距:國內型通常是下一個交易日公告,海外型是兩個交易日後;國外休市日沒有該資料日,下一個公告日會重發前一份。抓取目標日 T 時,從 T 的下一個交易日公告開始,資料日仍早於 T 才再查下一個公告日;遇到空回應、資料日沒有前進、資料日已到 T,或公告日超過台北今日的下一個交易日就停止,回傳資料日最新的那份。不設固定天數。
非空資料的來源缺少日期、日期矛盾、格式非法或超過台北今日時,provider 應失敗,不能回填查詢日。歷史 request 可安全送入 provider,但只代表會保留可信來源日,不保證目前頁面提供所有歷史日期。
正式 pipeline 將非空資料寫到來源日。來源日與 request 不同時,request 保留缺口,不能宣稱成功。官方明確空陣列保持 empty,中信此時不要求 FundAssets 日期;缺少或格式錯誤的明細欄位仍是 failed。本次沒有更改元大只含期貨頁面的既有排除規則。
2026-09-09 事件證據
| 範圍 | 錯置於 9/9 的筆數 | 已驗證來源 |
|---|---|---|
| 元大 37 檔 | 3,956 | 9/4:20 檔 2,490 筆;9/7:2 檔 435 筆;9/8:15 檔 1,031 筆 |
| 兆豐 9 檔 | 491 | 全部 9/8 |
| 中信 00406A | 64 | 9/8,該日已存在相同完整快照 |
共 47 檔 4,511 筆。原程式把 request 9/9 傳入每筆 HoldingRecord,所以即使資料內容正確,儲存日期仍錯誤。00990A 無日期參數頁為 9/4 的 47 筆;明確查詢 9/8 則為 49 筆,兩份不能互相冒充。00996A 屬兆豐,官方 9/8 共 55 筆。
原始來源:元大 00990A 指定 9/8、兆豐 00996A 商品頁、中信 00406A 持股頁。目前頁面會隨發行商更新;事件重播應使用已保存的完整原始回覆。
完整原始回覆與 DB-derived fixture 保存自然鍵、數量、權重、名稱、資產類型、sync 及邊界摘要。測試 fixture 位於 investment-agent/backend/tests/fixtures/etf_source_dates*。這些證據用於回歸與精確修復,不建立 candidate/clone/備份 DB。
正式修復入口
python -m scripts.repair_etf_source_dates 預設唯讀 dry-run。輸入 manifest 包含每檔 provider、ticker、錯置日、來源日、完整 holdings、原始回覆路徑與 SHA-256、provider source SHA-256。入口重新解析凍結原始回覆,比對日期與完整數值,再用正式股票身分正規化及 DB 四位小數規則核對現存資料。
所有 provider/ticker 篩選必須與 manifest 完全相等;DB host/name 及 PostgreSQL cluster 的 system_identifier 必須明確指定。Dev/SIT 可能使用相同 Docker host/DB 名稱,不能單靠 URL 辨識;先在已確認的 dev PostgreSQL 容器以唯讀 SELECT system_identifier FROM pg_control_system() 取得識別碼。入口另從實際連線讀取 current_database()、system_identifier、server address/port,前兩者必須符合預期並納入 plan SHA;server address/port 保留在報告供診斷,Docker IP 變動不會使同一 cluster 的計畫失效。以下的 SHA、manifest 路徑及 ticker 參數由本次完整 manifest 產生,不得省略範圍或改為全庫操作:
python -m scripts.repair_etf_source_dates \
--expected-database-host postgres \
--expected-database-name cortex_investment \
--expected-database-system-identifier "$ETF_REPAIR_DATABASE_SYSTEM_IDENTIFIER" \
--manifest "$ETF_REPAIR_MANIFEST" \
--expected-manifest-sha256 "$ETF_REPAIR_MANIFEST_SHA" \
--provider yuanta --provider mega --provider ctbc \
--ticker 0050.TW ... \
--wrong-date 2026-09-09 \
--report-path "$ETF_REPAIR_DRY_RUN_REPORT"
DATABASE_URL 由執行環境提供,不寫入報告或版本庫。確認 dry-run 的逐檔來源日、筆數與計畫 SHA 後,使用完全相同範圍,增加 --apply --expected-plan-sha256 <dry-run SHA> 及新的 --report-path。實寫前須完成當次精確命令與範圍核准;不要直接複製上述含省略符號的範例執行。
每檔使用 SERIALIZABLE 交易、statement timeout 15 秒及 lock timeout 2 秒。背景 pipeline 持鎖超時時會停止於該檔,避免長時間阻塞;待鎖釋放後重新 dry-run/續跑,不自動放寬 timeout。
每檔交易只處理 manifest 指定的兩個日期:
- 在該筆交易重新核對 database name/system_identifier,再鎖定並核對 DB 狀態;若有變動或目的日內容衝突,停止該檔。
- 移除已證實錯置的 request-date 持股,透過正式 writer 補入缺少的來源日。已正確存在的來源日維持原列與 timestamps。
- 來源日 sync 為 success;錯置 request 的原 sync 列改為帶
source_date_mismatch原因的 empty,保留其識別與開始時間。 - 交易內驗證完整結果後才 commit。失敗檔 rollback,報告列出已完成前綴及失敗 provider/ticker/兩個日期。
修復中斷後,保留同一 manifest,重新 dry-run 取得對應目前 DB 狀態的計畫 SHA,再續跑。已完成檔為 no-op,不能再次搬日期或重寫 timestamps。舊計畫 SHA 不可跨 DB 狀態沿用。Plan 同時固定 repair 入口、writer、base、models 及 provider 的 implementation bundle SHA;任何 bundle 檔案變更都必須重新 dry-run,包括 models.py 中與 ETF 修復無關的修改;這是刻意採用的整檔 fail-closed 邊界。
| Exit code | 報告 mode | 意義與處理 |
|---|---|---|
| 0 | dry-run | 唯讀計畫完成;僅此狀態的 safe_to_apply 為 true,仍須核准精確範圍 |
| 0 | applied | 指定項目全部處理完成,包括 no-op;不得解讀成所有日期已補齊 |
| 2 | refused | 沒有已完成項目;身分、證據、plan、drift、第一檔交易等驗證未通過 |
| 3 | partial | 已有完成項目,或 commit 期間中斷而無法確認該檔結果;保留 completed 前綴,重新 dry-run 查核實際狀態後續跑 |
Apply 必須使用新的 report-path;既有檔不覆寫。若 commits 後報告寫入失敗,exit 3 並在 stderr 明示,不能假定未寫入;改用新報告路徑重新 dry-run 查核實際狀態。連線清理失敗另外記錄 stderr,保留已保存的 applied/partial 報告;可捕捉的取消也須保留已提交前綴。DB URL 的原始/asyncpg 格式與原始/percent-encoded 密碼皆遮罩。此報告機制不保證 SIGKILL/主機斷電時能寫出報告,恢復仍以新 dry-run 的實際 DB 狀態為準。
統一投信連鎖改標(manifest version 2,#1947)
1947 之前,統一投信 adapter 把請求的目標日當成持股日。海外型 8 檔(00757、00853B、00931B、00966B、009811、00988A、00411A、00987D)因此整段晚一個交易日,國外休市日前後還有同一份寫成兩天的情形;國內型 3 檔(00403A、00939、00981A)不受影響。version 1 只能搬一個錯置日,所以統一投信改用 version 2:
python -m scripts.capture_ezmoney_pcf對指定基金與公告日範圍逐份保存原始回應(含請求內容與 SHA-256),並產生 manifest。manifest 只列回應檔與已核可的 [推論] 項,不列操作。- 修復工具重新解析每份回應,以「代碼主體(去掉
:YYYYMM)+股數+權重(四位小數)」的多重集合比對 DB 每個日期,由舊到新決定保持、改標、刪除重複,或套用 [推論] 項。比對不含名稱、資產類型、上市別:舊列的這些欄位有既有表示法差異(例如台指期貨(B)、裸期貨代碼記成股票、上櫃股記成tw),不屬日期問題,本修復不改寫。 - 改標只更新
as_of_date,保留 id、內容與updated_at;刪除只用於與保留那份指紋相同的重複日,而且必須有比該重複日更晚的已擷取公告仍重發同一份快照(例如美國假日後兩個公告日都帶前一份資料日),否則拒絕。改標目標日在處理當下必須是空的。 - 同步紀錄:改標後的資料日寫 success(列數相符);被空出的日期寫
source_date_mismatch並寫明來源日;原本沒有持股的 failed/empty/pending 日期若不是改標目標就不改寫,只列在報告。有持股卻不是 success 的日期會拒絕整檔,唯一例外是 [推論] 項的 DB 日。 - 完整性閘門:該檔 DB 每個有持股的日期都必須被涵蓋,任何一天找不到來源又不在 [推論] 清單就拒絕整檔,並列出那些日期的列數與指紋。
[推論] 項用於來源已撤回、DB 是唯一副本的快照(例如臨時休市前一晚擷取、之後投信撤回的公告)。每項需寫明 DB 日、推論資料日、核可日期、以 [推論] 開頭的依據、列數與快照指紋,且不同項目不可共用推論資料日。工具核對規劃後每項快照均以相同列數與指紋保留在推論資料日;缺失或不符就拒絕,即使原 DB 日可改配其他來源也不能略過。正常重跑時,推論快照已在目標日,原 DB 日可以由後續快照占用。DB 本身無法區分 [推論] 列與有來源的列,推論紀錄只在 manifest、報告與備份裡。
命令範圍:--provider ezmoney 與 --ticker 必須與 manifest 完全相等,不使用 --wrong-date。dry-run 報告列出每檔首日、末日、各操作筆數、空出日期、未改寫的無持股日期、同指紋有多個候選的日期(ambiguous),以及變更摘要 change_digest(只含操作,不含 DB 時間戳);核可對象是最後一次 dry-run 的 digest,apply 仍要求同一 plan SHA。每檔一個 SERIALIZABLE 交易,timeout 與 exit code 同上表。apply 後重跑 dry-run 應為 0 個操作。擷取之後才寫入、內容與前一天相同的日期沒有公告佐證,會被拒絕而不是刪除;請重新擷取。
python -m scripts.capture_ezmoney_pcf \
--ticker 00757.TW --ticker 00988A.TW ... \
--from-date "$FIRST_PUBLICATION" --to-date "$LAST_PUBLICATION" \
--output-dir "$ETF_REPAIR_EVIDENCE_DIR" --skip-existing \
--manifest-path "$ETF_REPAIR_MANIFEST" --inferred-json "$ETF_REPAIR_INFERRED"
python -m scripts.repair_etf_source_dates \
--expected-database-host postgres \
--expected-database-name cortex_investment \
--expected-database-system-identifier "$ETF_REPAIR_DATABASE_SYSTEM_IDENTIFIER" \
--manifest "$ETF_REPAIR_MANIFEST" \
--expected-manifest-sha256 "$ETF_REPAIR_MANIFEST_SHA" \
--provider ezmoney --ticker 00757.TW ... \
--report-path "$ETF_REPAIR_DRY_RUN_REPORT"
--from-date 是 DB 首日之前;DB 首日較晚的基金用 --ticker-from TICKER=YYYY-MM-DD 個別指定起點,所有基金在同一次執行產生同一份 manifest。capture_index.json 會累積多次執行的結果,重用的回應保留原擷取時間,並重新核對它屬於該基金。與 version 1 相同,不要直接執行含省略符號的範例。
上線順序:新 adapter 若在歷史修復前執行,會把正確資料日的內容 upsert 進仍錯位的日期,造成混合快照。所以要在 ETF 排程空檔先 apply,再部署新 adapter 並重啟 scheduler。apply 期間不可有其他 ETF 寫入:migration 173 讓每個 etf_holdings/etf_holdings_sync 寫入語句都更新同一列快取版本,修復交易會持有這列直到 commit,其他寫入者會等待並可能觸發 2 秒 lock timeout;若無法確認排程空檔,先停 scheduler。重啟會立刻執行啟動補跑,重試 7 天內缺口並重算主動式 ETF 日報,屬寫入步驟。
修正後的日常行為:海外型 T 日資料要到 T+1 晚上才發布,T 日在那之前是 source_date_mismatch 缺口;國外休市日沒有該資料日,會在 7 天回看內持續列為缺口。下游歷史快取(籌碼日報的 ETF 變動、綜合摘要)若未重建,引用的是修正前的日期。
排程重試與發布時序
元大 daily request 仍明確指定台北今日的 navDate。尚未發布時維持 empty;不改抓無日期的舊頁、不推定固定 T-1 日期。事件中,9/9 實查 navDate=2026-09-08 的完整 54 檔得到 37 檔成功、17 檔依原規則 empty、0 日期不符;此證據支持跨日重試,沒有證明固定晚間發布時刻。
retry_recent_etf_holdings_gaps 只在設定的 lookback/交易日範圍補 empty/failed。能提供指定日期的 provider 不再要求該 ETF 曾有 success,因此首次發布也會補抓。兆豐與安聯只有目前頁面,serves_requested_as_of_date=False;歷史 empty/failed 不再送 GET 或改寫 sync,當日抓取照常,未能取得的歷史來源維持缺口。
最近缺口掃描對「historical、empty、provider/ETF 從未 success、首次 sync 日期早於本次 lookback 起點」檢查 finished_at:台北今日已完成嘗試的 target 跳過 GET 及 sync 寫入,記 skipped_daily_empty;隔日重新評估,NULL 仍可重試。這是頻率限制,不把空資料判成永不揭露或成功。首次 sync 位於 lookback 內(含起點)、任一日曾 success、failed 及當日 request 維持原重試頻率。明確指定單日的 CLI retry 不套排程節流。此檢查重用既有每 ETF commit 與 finished_at,取消後僅跳過已完成的前綴;它不是跨並行 job 的全域鎖。
本次修復保留的 9/9 empty,當日仍可重試;跨日後依上述能力分流。Mixed-date 等資料契約錯誤以 source_data_contract_violation 原因記錄 failed;來源後續一致時仍能在 bounded retry 恢復。狀態由既有 etf_holdings_sync、RetryStats、scheduler log/update_runs 追蹤,不把 failed 或 empty 標成成功。
PostgreSQL 回歸入口
CI 的 schema-parity job 已納入執行清單: tests/integration/test_repair_etf_source_dates_pg.py,重用已建立且完成 migration 的 parity DB,不另建資料庫。正式本機 integration runner 的預設清單也包含此檔;必須同時提供下列三個環境變數,缺任一項就 skip:
ETF_SOURCE_DATE_REPAIR_TEST_DATABASE_URL=postgresql+asyncpg://<credentials>@<host>/<database>
ETF_SOURCE_DATE_REPAIR_TEST_DATABASE_NAME=<database>
ETF_SOURCE_DATE_REPAIR_TEST_ALLOW_TEMP_WRITE=1
將三項放入僅操作者可讀的 env-file,透過 repository runner 執行:
CORTEX_INTEGRATION_ENV_FILE="$ETF_REPAIR_TEST_ENV_FILE" \
CORTEX_INTEGRATION_NETWORK="$ETF_REPAIR_TEST_NETWORK" \
scripts/run_backend_tests.sh integration \
tests/integration/test_repair_etf_source_dates_pg.py \
--noconftest -v --tb=short
測試只寫同一 PostgreSQL session 的 TEMP tables/自有 sequences,以真實逐檔 commit 驗證首次修復、中斷回滾、已提交前綴、同範圍重跑及 drift/交易內驗證。完整凍結來源建立 expected holdings,DB fixture 獨立建立失敗狀態;正式 normalizer 的股票參照 SELECT 僅在測試中改指 TEMP stocks。每個 production session 建立前斷言沒有外層 transaction,確保測到真正 commit;將清理讀取交易的 helper 改為 no-op 時,測試必須失敗。連線關閉後 TEMP 消失,永久資料與 sequences 不變。
Oracle 邊界:原始官方回覆獨立證明來源日期、原始成分代碼、名稱與數值;canonical fixture 的 constituent_ticker 是當時正式 normalizer 的凍結輸出,TEMP stocks 亦來自既有 DB 對照,因此只能驗證正式正規化確實被呼叫、輸出保持一致,不能獨立證明整份股票上市別參照資料正確。本 PR 不修改該參照資料或 normalizer 規則。
歷史重試回歸另保存 2026-09-09 16:35 台北從 dev 唯讀取得的 9/2~9/8 全部 143 筆 empty/failed 及各 pair 的首次 sync/首次 success。129 筆從未 success 中,57 筆當天已完成嘗試;重播首輪只呼叫其餘 72 筆及 13 筆仍需頻繁恢復的缺口,同日後續僅 13 筆。測試同時驗證 16 筆群益長期 empty 不發 GET、00990A 首次揭露完整 49 筆恢復,以及第二檔寫入後取消時只保留第一檔 commit、重試續做其餘項目。
補齊與驗收
錯置修復與缺資料補抓是兩步。校正 00990A 的 9/4 快照後,仍須以正式 navDate=2026-09-08 補入真正的 49 筆;不能把這次日期修復當成 9/8 已完成。
完成後驗證:
- 每筆來源自然鍵與數值相符,9/9 無本次錯置的持股。
00990A.TW?date=2026-09-08回傳 49 筆,00996A.TW?date=2026-09-08回傳 55 筆;latest 及 recap 使用正確資料日。- 相同範圍重跑 no-op;部分失敗 rollback 及恢復結果一致。
- 非目標 provider/日期與原有正確目的日的筆數、內容及 timestamps 沒有變動。
- 缺少來源的其他日期與獨立 provider 問題分開列出,不宣稱整個資料流程完成。
不得偽造 coverage、清除錯誤冒充成功、切換 dev DB,或觸發無關指標、SES、Strategy V7 全量重算。