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四欄(migrationinvestment-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_windowcron 已註冊 (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
理由(三條,任一發生都會弄髒本次修復或反過來被本次修復燒光配額):
cortex-scheduler的 15:30 每日更新/夜間維護窗會在重建過程中對同一批 raw 列繼續寫入,與本次 backfill 的每檔單一交易(engine.begin())互相 覆蓋而非疊加。- scheduler 上其他會消耗 FinMind 配額的既有 backfill(季報/投組 backfill)在遇到 FinMind 402(配額超限)時是靜默 checkpoint(視同 完成跳過),不是重試。本次 raw 重建以 register 級 600 calls/hr 的配額 連續跑 ~5 小時,若 scheduler 同時在跑,會把整個帳號的每小時配額燒光, 讓那些 backfill 誤把「配額用盡」當成「這批做完了」而漏資料。
- 夜間維護窗(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 現值:
stocks表is_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.yaml→data_providers.finmind.rate_limits(investment-agent/backend/config/finmind_config.py的FINMIND_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:全部完成,且收尾審計(見下)通過。1(EXIT_TICKER_FAILURES):有系統性失敗的 ticker。2(EXIT_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_close(StockCRUD.save_adj_ohlc_batch,
UPDATE-only、不會 INSERT——yfinance 有但 raw 沒有的日期算作
pending_rows,代表 raw 重建對那檔還不完整)。指數(僅 ^TWII)不處理
——指數沒有除權息,adj 基準就是 15:30 寫入的官方 bar。
分片並行
用 N 個獨立、互斥且覆蓋全域的 docker run --rm 容器(--shard I/N,
I 是 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_state(trigger='initial_backfill'),沿用夜間 drain 同一套 queued → started → completed 三態: - 失敗的檔:
started_at有值、completed_at為 NULL(interrupted)。 cortex-scheduler重啟時(_requeue_interrupted_adj_rebuilds,scripts/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 參數如下:
- 位置參數
check:abcdefall(all一次跑完六個), argparsechoices限定,其餘值直接被拒絕。 --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-table:d與all必填(未給即 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_violations(check=all時在reports.<check>.summary.total_violations)。1:執行本身失敗(缺--db-url、d/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.py:resolve_config()的搜尋順序是["/app/cortex_config", "config"],/app/cortex_config優先。investment-agent/docker-compose.yml:agent-api與scheduler兩個 服務都把../config:/app/cortex_config:ro掛進容器——../config相對investment-agent/就是 repo 根目錄的config/。investment-agent/backend/Dockerfile:COPY 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.yaml 的
ohlc_adj_detection_enabled 對 production 沒有任何效果;backend 那份
是給「不經 Docker、直接在 investment-agent/backend/ 本機跑腳本」時的
resolve_config() 第二順位候選(config 相對路徑),只是保留給本機開發
對稱用。PR-A(#1263)已經把兩份 core.yaml 的 ohlc_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_integrity(CAPABILITY_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.yaml 的 ohlc_adj_detection_enabled: false → PR →
merge → git pull → docker 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_TIME(services/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.py的CronTrigger定義逐一核對後的結果。
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, 尚未在本文件範圍內完成。