跳轉到

Runbook:台股 OHLC raw/adj 分欄全史修復(issue #1253 PR-B)

對應 PR-A(#1263,已 merge 2026-08-07)在 stock_ohlc 拆出的 adj_open/adj_high/adj_low/adj_close 四欄(migration investment-agent/backend/database/migrations/123_ohlc_adj_columns_and_state_tables.sql)。 本 runbook 是 PR-B:把歷史髒資料(raw 欄位混雜 yfinance 回調段、FinMind fallback 日、舊長窗 backfill)修回乾淨的 raw/adj 分欄。完成後 raw/adj 兩欄皆乾淨,但下游消費點切換與指標全史重算屬 PR-C,本文件範圍不含。

0. 前置狀態(執行前先確認)

  • PR-A #1263 已 merge;live migration 123 已套用(2026-08-07); cortex-scheduler 已重啟;ohlc_maintenance_window cron 已註冊 (mon-sat 00:30,見下方「時窗紀律」)。
  • scheduler.ohlc_adj_detection_enabled 出廠關閉(false)——見 §8「開偵測 flag」,本次修復完成、驗證通過前不得開啟。
  • 本 PR(PR-B)merge 後,第一步是主 repo git pull,再往下走 §1 開始的程序。

1. 停 scheduler

docker stop cortex-scheduler

理由(三條,任一發生都會弄髒本次修復或反過來被本次修復燒光配額):

  1. cortex-scheduler 的 15:30 每日更新/夜間維護窗會在重建過程中對同一批 raw 列繼續寫入,與本次 backfill 的每檔單一交易(engine.begin())互相 覆蓋而非疊加。
  2. scheduler 上其他會消耗 FinMind 配額的既有 backfill(季報/投組 backfill)在遇到 FinMind 402(配額超限)時是靜默 checkpoint(視同 完成跳過),不是重試。本次 raw 重建以 register 級 600 calls/hr 的配額 連續跑 ~5 小時,若 scheduler 同時在跑,會把整個帳號的每小時配額燒光, 讓那些 backfill 誤把「配額用盡」當成「這批做完了」而漏資料。
  3. 夜間維護窗(00:30–06:00)會在修復進行到一半時開始 drain adj 佇列, 與本次 B2(adj 初建)搶同一批 stock_ohlc_adj_rebuild_state 列。

cortex-api 照常在跑(--reload + bind mount,見 investment-agent/docker-compose.yml)——修復期間 API 讀到過渡態資料 (raw 已重建但 adj 尚未初建、或反之)屬預期,不需要連 API 一起停。

2. 備份(B1 腳本自動處理)

scripts/backfill_taiwan_ohlc_raw.py 在寫入前,會用 CREATE TABLE stock_ohlc_backup_pre_1253_<yyyymmdd> AS SELECT * FROM stock_ohlc WHERE market = 'taiwan' 建立當日快照(沒有 IF NOT EXISTS——同名重複執行會直接 fail-loud,不會悄悄沿用舊快照)。

同一天中斷續跑時,必須顯式加 --skip-backup(會印出下列 WARNING,且 這次不會建立新快照,回滾點沿用被中斷那次的快照):

--skip-backup: no snapshot taken this run; the rollback point must be the
backup table of the interrupted run being resumed

沒有 --skip-backup 的續跑會嘗試重建同名表,直接因為表已存在而失敗—— 這是刻意設計(fail loud 優於悄悄延用舊快照)。

3. Dry-run(配額成本與實跑相同——這是核可用的報告,不是免費預覽)

cd investment-agent
docker run --rm --network investment-agent_cortex-network \
  -v "$(pwd)/logs:/app/logs" \
  -e DATABASE_URL=postgresql+asyncpg://cortex:<DB_PASSWORD>@postgres:5432/cortex_investment \
  investment-agent-agent-api \
  python scripts/backfill_taiwan_ohlc_raw.py \
    --dry-run --report-path /app/logs/backfill_raw_dry_run.jsonl

重要事實:dry-run 一樣要逐檔打 FinMind 才能知道會刪什麼列(見 preview_ticker_backfill/fetch_ohlc_with_retry),所以配額與時間成本 與實跑完全相同

  • 台股 tickers 數:現況 ~2,388 檔(live DB 現值:stocksis_active=true AND market='taiwan' 為 2,389 檔,扣掉 1 檔指數 ^TWII = 2,388;與 raw 腳本自己的 TAIWAN_TICKER_RANGE_SQL 掃出的 taiwan ticker 母體同一數量級)。
  • FinMind register 級配額 600 calls/hr;節流延遲讀自 config/core.yamldata_providers.finmind.rate_limitsinvestment-agent/backend/config/finmind_config.pyFINMIND_INTER_REQUEST_DELAY_SECONDS),目前值 7.5 秒/次requests_per_minute: 8)。
  • 估時:2,388 × 7.5s ≈ 17,910s ≈ 4.98h(≈5h)

輸出是逐檔一行的 JSON Lines 報告(--report-path),每行含 ticker / action(preview|abort|failed) / payload_start / payload_end / would_upsert_rows / would_delete_count / would_delete_dates / errors

硬 gate:這份報告必須呈使用者明確核可後才可進入 §4 實跑,不可跳過。

排程建議:dry-run 與實跑各自 ~5h,建議分兩晚跑(例如今晚 dry-run、取得 核可後隔晚實跑),或若當晚時間夠、核可也已取得,可同日接續跑——但接續跑 的實跑必須是全新一次呼叫(沒有 --skip-backup 的必要,因為 dry-run 完全 不寫入、也不佔用 backup table 名稱)。

4. 實跑 raw 重建

取得 §3 報告核可後:

cd investment-agent
docker run --rm --network investment-agent_cortex-network \
  -v "$(pwd)/logs/runtime-state:/app/logs/runtime-state" \
  -e DATABASE_URL=postgresql+asyncpg://cortex:<DB_PASSWORD>@postgres:5432/cortex_investment \
  investment-agent-agent-api \
  python scripts/backfill_taiwan_ohlc_raw.py
  • ≈5h(同 §3 估算,配額成本不重複付兩次——dry-run 與實跑各自各付一次)。
  • checkpoint 續跑:每檔一個 transaction,成功或「abort」(無資料/驗證 失敗,非系統性錯誤)都會被寫進 checkpoint 檔(預設路徑 CORTEX_RUNTIME_STATE_DIR/backfill_taiwan_ohlc_raw.checkpoint;映像 內 CORTEX_RUNTIME_STATE_DIR 已 bake 成 /app/logs/runtime-state,見 investment-agent/backend/Dockerfile)。上面的指令必須把 host 的 investment-agent/logs/runtime-state 掛進 /app/logs/runtime-state ——沒掛的話 checkpoint 只存在該次 --rm 容器裡,容器一消失、中斷續跑就 失去意義。只有系統性失敗(非 abort)的 ticker 不會被 checkpoint,續跑 時會重試;連續失敗達 data_providers.finmind.abort_on_consecutive_failures(目前 3 次)會 整批中止。
  • exit code
  • 0:全部完成,且收尾審計(見下)通過。
  • 1EXIT_TICKER_FAILURES):有系統性失敗的 ticker。
  • 2EXIT_VALIDATION_FAILED):收尾審計失敗(優先於 1——兩者都發生 時回傳 2)。
  • 收尾審計:跑完後統計 market='taiwan'close 有小數點兩位以上 精度的列數(TWSE raw 報價最多兩位小數,超過即代表殘留回調污染),容忍 值 BACKFILL_OHLC_FRACTIONAL_TOLERANCE_ROWS = 0——目前零容忍,只要 掃到 1 列污染就是 exit 2。

5. adj 初建(B2,backfill_adj_ohlc.py

Raw 重建(§4)exit 0 之後才可以開始。這是一次性腳本:對每檔台股跑 yfinance period="max", auto_adjust=True,把整段還原後的歷史填進 adj_open/adj_high/adj_low/adj_closeStockCRUD.save_adj_ohlc_batchUPDATE-only、不會 INSERT——yfinance 有但 raw 沒有的日期算作 pending_rows,代表 raw 重建對那檔還不完整)。指數(僅 ^TWII)不處理 ——指數沒有除權息,adj 基準就是 15:30 寫入的官方 bar。

分片並行

用 N 個獨立、互斥且覆蓋全域的 docker run --rm 容器(--shard I/NI 是 0-based,腳本自己的用例是 4 片):

cd investment-agent
for i in 0 1 2 3; do
  docker run --rm --network investment-agent_cortex-network \
    -e DATABASE_URL=postgresql+asyncpg://cortex:<DB_PASSWORD>@postgres:5432/cortex_investment \
    investment-agent-agent-api \
    python scripts/backfill_adj_ohlc.py --shard ${i}/4 &
done
wait

先跑 --dry-run(不分片也可以,僅列出每檔目前狀態 new/interrupted/ completed,零寫入)確認候選數量再決定要不要分片、分幾片。

節流與估時

_yfinance_rate_limiter()scripts/scheduler.py)讀 data_providers.yfinance.rate_limits.requests_per_minute(config 現值 60,即每次 acquire() 最少間隔 1.0 秒)。限流器是 process 內 記憶體狀態——每個 docker run 容器各自一份,彼此不共享,所以 N 片並行 時聚合請求量是 N×60/min(這只是節流器算出的下限,不代表 yfinance 端真的 能撐住這個聚合量——沒有相關的實測數據,本文件不捏造保證)。

單純節流下限(不含每檔實際 HTTP round-trip + DB 寫入時間,只是 acquire() 間隔累加,真實耗時只會更長): (2,388 / N) × 1.0s。以腳本用例 N=4 為例 ≈ 597 檔/片 × 1.0s ≈ 10 分鐘 下限——實際請以每片日誌的 [adj backfill] ticker=... status=completed 節奏推算真實進度,不要以這個下限當作預期完工時間。

失敗語義

  • exit code:任一檔失敗(stats["failed"] > 0)→ SystemExit(1);否則 0(含全部 skip-as-completed 的情況)。
  • 每檔的佇列狀態存在 stock_ohlc_adj_rebuild_statetrigger='initial_backfill'),沿用夜間 drain 同一套 queued → started → completed 三態:
  • 失敗的檔:started_at 有值、completed_at 為 NULL(interrupted)。
  • cortex-scheduler 重啟時_requeue_interrupted_adj_rebuildsscripts/scheduler.py)會把所有 started_at IS NOT NULL AND completed_at IS NULL 的列重置回 queued(不分 trigger 來源),下一個 維護窗的 _drain_adj_rebuild_queue 就會自動撿回去跑。
  • 或者直接重跑 backfill_adj_ohlc.py(不加 --force):已 completed 的檔會跳過,只重試未完成的。

邊界(刻意不做的事,非遺漏)

  • 不觸發 D12 重算鏈:一般 adj 全史改寫後要接指標/MA/forward-return 重算(scheduler._trigger_adj_rebuild_followup),但初建是全市場一次 性重寫,逐檔觸發等於把整個 universe 的重算鏈跑一遍。這條鏈留給 PR-C 的全史一次性重算,本腳本跑完後衍生值仍停在舊基準,直到 PR-C 跑過。
  • 不處理指數。

6. 驗證

scripts/verify_ohlc_basis_repair.py:六個唯讀檢查((a)–(f),各自的檢查 內容見該檔案 module docstring),子命令對照 CLI 參數如下:

  • 位置參數 checka b c d e f allall 一次跑完六個), argparse choices 限定,其餘值直接被拒絕。
  • --db-url:SQLAlchemy async DB URL;不給就讀 DATABASE_URL 環境變數 (os.environ.get("DATABASE_URL", ""));兩者皆空 → exit 1。
  • --report-path:JSON 報告寫入路徑;不給就印到 stdout。
  • --sample-tickers:僅 (c) 用,對照 FinMind 的抽樣檔數,預設 20 (DEFAULT_SAMPLE_TICKERS)。
  • --backup-tabledall 必填(未給即 exit 1);值需通過 validate_backup_table_name 白名單正則(對照 §2 建出的 stock_ohlc_backup_pre_1253_<yyyymmdd> 快照表名;不符即 exit 1——這是 --backup-table 抵禦 SQL 注入的唯一防線,因為表名不能當 bind parameter)。

exit code 語義

  • 0:報告成功產出——不代表零違規,違規數量要看報告 JSON 裡的 summary.total_violationscheck=all 時在 reports.<check>.summary.total_violations)。
  • 1:執行本身失敗(缺 --db-urld/all--backup-table--backup-table 未通過白名單、或跑檢查過程中 DB/其他例外)。

驗證報告完成後附回 issue #1253。

7. 開偵測 flag(scheduler.ohlc_adj_detection_enabled: false → true

production 實際讀的是 repo 根目錄 config/core.yaml,不是 investment-agent/backend/config/core.yaml——這點與一般直覺相反,證據 如下(三條互相印證):

  • core/utils/config_resolver.pyresolve_config() 的搜尋順序是 ["/app/cortex_config", "config"]/app/cortex_config 優先。
  • investment-agent/docker-compose.ymlagent-apischeduler 兩個 服務都把 ../config:/app/cortex_config:ro 掛進容器——../config 相對 investment-agent/ 就是 repo 根目錄的 config/
  • investment-agent/backend/DockerfileCOPY config /app/cortex_config (build context 是 repo 根目錄),image 內建的 fallback 一樣是根目錄 那份,不是 investment-agent/backend/config/
  • investment-agent/backend/config/core.yaml 自己的註解也這樣寫 (192-193、197-198 行):「production reads root config/core.yaml via /app/cortex_config; kept here for symmetry — values must match the root copy」。

換句話說:只改 investment-agent/backend/config/core.yamlohlc_adj_detection_enabled 對 production 沒有任何效果;backend 那份 是給「不經 Docker、直接在 investment-agent/backend/ 本機跑腳本」時的 resolve_config() 第二順位候選(config 相對路徑),只是保留給本機開發 對稱用。PR-A(#1263)已經把兩份 core.yamlohlc_adj_detection_enabled 都加了 key(目前都是 false),開 flag 時兩份都要改(保持對稱、避免 下次有人誤讀哪份才是準的),但真正生效的只有根目錄那份。

流程:

# 兩份都改 scheduler.ohlc_adj_detection_enabled: false → true
#   config/core.yaml
#   investment-agent/backend/config/core.yaml
# 走小 PR → merge → 主 repo:
cd <主 repo>/investment-agent && git pull    # 或所在目錄的等效 pull
docker restart cortex-scheduler

驗證:下一個維護窗(mon-sat 00:30)的 cortex-scheduler log 應看到 [OHLC adj] 前綴的偵測/佇列訊息;_record_ohlc_integrity_health 寫入的 ohlc_integrityCAPABILITY_OHLC_INTEGRITY)健康條目應反映本次窗口跑過 的 gap scan / missed-day repair / adj drain 統計。

8. 回滾路徑

raw:從備份表還原

INSERT INTO stock_ohlc
  (market, ticker, timestamp, open, high, low, close, volume,
   adj_open, adj_high, adj_low, adj_close, updated_at)
SELECT market, ticker, timestamp, open, high, low, close, volume,
       adj_open, adj_high, adj_low, adj_close, NOW()
FROM stock_ohlc_backup_pre_1253_<yyyymmdd>
ON CONFLICT (market, ticker, timestamp) DO UPDATE SET
  open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low,
  close = EXCLUDED.close, volume = EXCLUDED.volume,
  updated_at = CURRENT_TIMESTAMP;

(原樣取自 backfill_taiwan_ohlc_raw.py 模組 docstring)——只覆寫 raw 五欄,不動 adj_*:被 bounded DELETE 移除的列會連同還原前的 adj_* 一起復原,倖存的列保留 yfinance 路徑寫入的 adj_*

flag:關回 false

改回兩份 core.yamlohlc_adj_detection_enabled: false → PR → merge → git pulldocker restart cortex-scheduler

單檔 adj 重置

UPDATE stock_ohlc
SET adj_open = NULL, adj_high = NULL, adj_low = NULL, adj_close = NULL
WHERE market = 'taiwan' AND ticker = :ticker;

接著用 --force 觸發重建(跳過「已 completed 略過」的判斷,會排一列 新的 initial_backfill queue 記錄,不是重開舊的那列):

docker run --rm --network investment-agent_cortex-network \
  -e DATABASE_URL=postgresql+asyncpg://cortex:<DB_PASSWORD>@postgres:5432/cortex_investment \
  investment-agent-agent-api \
  python scripts/backfill_adj_ohlc.py --ticker <ticker> --force

9. 時窗紀律(避開既有排程,見 scripts/scheduler.py_register_*_job

排程 時間(Asia/Taipei) 說明
每日更新 daily_stock_update 週一至週五 15:30 _DAILY_STOCK_UPDATE_TIMEservices/daily_update_window.py
週更 weekly_full_update 週五 20:00 「Weekly Full Stock Update」,近 30 天 OHLC 補強;未見明確時長標注
Lagging catch-up lagging_dataset_catchup_* 週一至週五 20:00 / 21:30 / 23:00 三個固定觸發時間點(非連續區間),補上市融資/估值/大宗商品/籌碼等晚到資料源
週更 discovery weekly_discovery 週日 02:00 6–12h(見 config/core.yaml scheduler 區塊註解),因此維護窗的 days 排除週日
OHLC 維護窗 ohlc_maintenance_window 週一至週六 00:30–06:00 本次修復所屬的偵測/drain 窗口本身

注意:週更 discovery 是週日 02:00、6–12h,跟週五 20:00 的 weekly_full_update 是兩個不同 job(不要混為一談)——這是本文件依 scripts/scheduler.pyCronTrigger 定義逐一核對後的結果。

cortex-scheduler 停用期間(§1)上述大部分衝突自然消失,但恢復 scheduler 前務必先確認 §4/§5 的批次已經全部收尾(checkpoint / stock_ohlc_adj_rebuild_state 都不再有進行中的列),否則重啟後的 _requeue_interrupted_adj_rebuilds 會把還在跑的當成「中斷」重新排入 佇列,跟手動還在跑的容器打架。

10. 注意事項

  • 禁止 docker cp 進 bind-mounted /app(見 feedback_docker_cp_bind_mount 慣例);本文件所有臨時容器一律 --rm
  • SES 歷史在 adj 全史重寫後為 stale(follow-up #1259): scripts/scheduler.py 的 D12(vi) followup 已埋 [SES stale] 完成日誌 (market=%s ticker=%s reason=known_gap completed_steps=%d total_steps=%d failed_step=%s),可用它在 log 裡追蹤哪些 ticker 的 SES 落在舊基準上,等待 PR-C。
  • 本 runbook 完成(raw 重建 + adj 初建 + 驗證 + 開 flag)後,raw/adj 兩欄本身皆已乾淨,但下游消費點切換與衍生指標的全史重算屬 PR-C, 尚未在本文件範圍內完成。