跳轉到

Runbook:台股指標/回測全史重算(issue #1253 PR-C)

對應 PR-C(#1253 方向 3)在三個中心 loader(_load_ohlc_series / _load_ohlc_series_batchStockCRUD.get_ohlc、各 backfill 腳本內嵌 SQL)加上 basis 參數,把指標序列 / forward returns / 回測改讀 adj_open/adj_high/adj_low/adj_close(還原價)。本 runbook 是 PR-C 的 C7:切換上線後,把 daily_technical_snapshot / forward_return_label / market_regime_daily / market_regime_segment / strategy_rule_catalog 這些已用舊(raw 基準)指標值填滿的表全史重算一次,改用新(adj 基準) 值。前置的 raw/adj 兩欄本身修復屬 PR-B,見 docs/runbooks/1253-ohlc-history-repair.zh-TW.md;本文件範圍不含 raw/adj 分欄修復本身,只處理消費點切換後的下游重算。

Migration 124(daily_technical_snapshotath_price_adj / high_52w_price_adj,D3 決策=C)與本次重算同批次上線,見 §0。 Migration 125(同表加支撐族 9 個 _raw 名目基準孿生欄)走專案標準流程 ——merge 後立即 apply、再重啟 scheduler,與本次重算時程脫鉤;但重啟 cortex-scheduler 前必須確認它已 apply(§0.3b 的驗證 SQL),否則每日 快照 INSERT 會整條失敗。其歷史列回填見 §5.7。

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

  1. PR-C 已 merge,主 repo git pullcd <主 repo>/investment-agent && git pull,或所在目錄的等效 pull)。
  2. Gate:B7 驗證報告 (a)–(f) 全綠(adj 基準乾淨——verify_ohlc_basis_repair.py 六個唯讀檢查,見 PR-B runbook §6)。B7 未綠之前,本文件任何一步都不可執行: adj 欄本身有污染時,全史重算只是把污染從「未使用」變成「已使用並持久化進 衍生表」,比不重算更難清。
  3. Migration 124 手動 apply(比照 migration 123 的 apply 慣例, scripts/apply_pending_migrations.sh 是 CI/deploy-sit/手動三方共用的 單一定義):
cd <主 repo>
PG_CONTAINER=cortex-postgres \
  investment-agent/backend/scripts/apply_pending_migrations.sh apply

遷移本身 all-nullable、無隨遷移的資料回填(見 database/migrations/124_ath_adj_columns.sql 註解);ath_price_adj / high_52w_price_adj 兩欄如何落值見 §5.5/§5.6——§2 的 6 步重算鏈不會寫 這兩欄,由 §5.6 的 backfill_ath_adj_history.py 另外補齊。 3b. Migration 125 手動 apply(支撐族 _raw 孿生欄,9 欄):與 124 用同一條 指令(apply_pending_migrations.sh apply 會把所有 pending 一次帶上), 同樣 all-nullable、無隨遷移回填,見 database/migrations/125_support_raw_columns.sql 註解。

這一步是 cortex-scheduler 重啟的硬閘門,理由與 124 不同: scripts/daily_analysis_snapshot.pyMAIN_TABLE_COLUMNS 已加入這 9 欄, 而每日快照是單一 INSERT 帶全部欄位scripts/daily_analysis_snapshot.py:4243-4259)——遷移未 apply 就重啟 scheduler,該 INSERT 會因 column ... does not exist 整條失敗,全市場 當日快照連坐中斷,不是只有這 9 欄變 NULL。

重啟前先跑這條驗證 SQL,回傳必須是 9

SELECT COUNT(*)
FROM information_schema.columns
WHERE table_name = 'daily_technical_snapshot'
  AND column_name IN (
    'major_support_raw', 'major_resistance_raw',
    'low_20d_min_raw', 'low_60d_min_raw', 'structure_stop_raw',
    'distance_to_support_20d_pct_raw', 'distance_to_support_60d_pct_raw',
    'distance_to_support_20d_price_raw', 'distance_to_support_60d_price_raw'
  );

這 9 欄的歷史列如何落值見 §5.7(backfill_support_dual_history.py); §2 的 6 步重算鏈同樣不會寫它們。 4. cortex-scheduler

docker stop cortex-scheduler

理由(三條,任一發生都會把重算結果與排程寫入互相覆蓋或誤判):

  • cortex-scheduler 對單一 ticker 的 adj 全史改寫已有一條自動 D12 跟進鏈(scripts/scheduler.py:932-940full_history_indicators → real_ma_periods(force) → ma_slope → ma_rally_reject → ma_pullback_hold → ma5_ma20_cross → forward_returns), 由夜間維護窗或個別 ticker 的 adj rebuild 觸發。本次是全市場一次性 批次重算,若 scheduler 同時在跑,兩者會對同一批 daily_technical_snapshot 列搶寫,造成 updated_at 竞態與部分列 用了不同批次的输入(見下方 §3 完成確認 SQL 的用途)。
  • 15:30 每日更新/夜間維護窗與本次全史重算共用同一批 daily_technical_snapshot 列——排程繼續寫入等於邊重算邊被覆蓋,且 順序不可控。
  • import_rule_catalog_unified.py 已知會被 scheduler 排程呼叫 (scripts/scheduler.py:2990-2992),若在 §5 手動重跑 rule_regeneration.py 產出新 strategy_rules.json 期間 scheduler 恰好觸發舊排程匯入,會用尚未反映 adj 切換的舊檔覆蓋。

cortex-api 可以照常在跑(--reload + bind mount,重算期間 API 讀到 「部分表已切換、部分還沒」的過渡態資料屬預期,不需要連 API 一起停)。

1. 共用慣例

  • Image:investment-agent-agent-api(沿用 PR-B runbook 的既有 image, investment-agent/backend/Dockerfile 建置)。
  • Network:investment-agent_cortex-networkdocker-compose.yml 專案名 investment-agent 前綴出的預設網路,已用 docker network ls 核對存在)。
  • DATABASE_URL 從 cortex-api env 帶入(不重新輸入密碼):
cd investment-agent
DATABASE_URL=$(docker exec cortex-api printenv DATABASE_URL)
  • Mount 唯讀 codeagent-api 自己的 compose 條目是 ${BACKEND_PATH:-./backend}:/app:delegated(可寫、給 --reload 用);本文件的一次性 docker run --rm 容器一律用唯讀掛載,確保跑的是 §0.1 git pull 後的最新程式碼,不依賴「image 是否剛好重 build 過」這個 不確定前提:
-v "$(pwd)/backend:/app:ro" \
-v "$(pwd)/../core:/app/core:ro" \
-v "$(pwd)/../config:/app/cortex_config:ro" \
-e PYTHONPATH=/app:/app/core \

PYTHONPATH 值照抄 docker-compose.ymlagent-api 條目;config 掛 /app/cortex_config 是因為 core.utils.config_resolver.resolve_config() 優先找這個路徑——見 PR-B runbook §7 對這條路徑的完整證據鏈。) - 完整範例(以 §2 步驟 1 為例,其餘步驟只換 python scripts/... 那一行):

cd investment-agent
docker run --rm --network investment-agent_cortex-network \
  -v "$(pwd)/backend:/app:ro" \
  -v "$(pwd)/../core:/app/core:ro" \
  -v "$(pwd)/../config:/app/cortex_config:ro" \
  -v "$(pwd)/logs:/app/logs" \
  -e DATABASE_URL="${DATABASE_URL}" \
  -e PYTHONPATH=/app:/app/core \
  investment-agent-agent-api \
  python scripts/backfill_full_history_indicators.py --workers 8

2. 重算鏈(§3 順序 1→2→3→4→5→6,daily_technical_snapshot /

forward_return_label / market_regime_*

順序固定、不可交錯並行(見 §3)。每步都是全市場一次跑(不逐 ticker 分批 docker run;平行手段見各步「並行」小節)。

步驟 1:backfill_full_history_indicators.py(全史指標重烘)

python scripts/backfill_full_history_indicators.py --workers 8
  • basis:本 PR 已把 OHLC 讀取硬編為 basis=OHLC_BASIS_ADJscripts/backfill_full_history_indicators.py:683),沒有 --basis 旗標——本腳本永遠讀 adj,不需要額外指定。
  • 並行:唯一有原生平行機制的一步——--workers N1 <= N <= 8MAX_WORKERSscripts/backfill_full_history_indicators.py:145)啟動 process pool 在單一容器內平行算指標,不是多容器分片;其餘步驟(2–6) 沒有 --shard 或 ticker 範圍旗標,本文件不編造分片機制——見 §3.1「分片限制」。
  • 冪等性:per-ticker executemany UPDATE、逐 ticker 各自一個 transaction (模組 docstring:「updates are idempotent, so re-running (optionally with --resume-from) is always safe」)。中斷續跑: --resume-from <ticker>(字典序游標,只有下界、沒有上界旗標,見 scripts/backfill_full_history_indicators.py:399-402)跳過已排序在前的 ticker;因為冪等,也可以整批重跑不加任何旗標。
  • 完成判準main() 不檢查失敗數、不 sys.exit——即使有 ticker 失敗 (tickers_failed),process exit code 仍是 0(scripts/ backfill_full_history_indicators.py:907-927 沒有任何 SystemExit)。 唯一的完成訊號是最終 log 行:
Done: N tickers (M skipped, no OHLC), R rows updated, ... F failed, ...

F(failed)必須為 0 才算完整;exit code 本身不可信。

步驟 2:backfill_real_ma_periods.py --force(MA60/120/240 真實週期)

python scripts/backfill_real_ma_periods.py --force
  • --force 必帶:預設(不帶 --force)只處理 ma60/ma120/ma240 三欄有任一為 NULL 的列 (scripts/backfill_real_ma_periods.py:75-78,95-96)——adj 切換後這些 欄位早就被舊 raw 值填滿、非 NULL,不帶 --force 等於靜默 no-op(PR-A 已知陷阱,計畫 §3 步驟 2 原文)。
  • basis:SQL 內嵌 o.adj_close AS closescripts/ backfill_real_ma_periods.py:252,314),無 --basis 旗標。
  • 冪等性--force 模式下沒有跳過已完成 ticker 的機制(force=Trueskip_already_populated=False),每次整批重算全部 UPDATE——冪等體現在 「重算結果每次相同」而非「已完成的省略」。沒有per-ticker try/exceptscripts/backfill_real_ma_periods.py:571-596 backfill_real_ma_periods() 主迴圈無 try)——任一 ticker 拋例外會讓整個 process 崩潰,之前已 commit 的 ticker(_commit_in_chunks--batch-commit-size,預設 500,即 commit 一次)保留,之後字母序在後的 ticker 完全沒跑到。中斷續跑:直接重跑同一條指令(--force 必帶,因為 剛才半途中斷的那批也需要重新蓋掉),或加 --ticker <ticker> 鎖定單一 ticker 驗證。
  • 完成判準:main() 也不檢查失敗、不 sys.exit——完成訊號是 log 行 Backfill complete: {'total': N, 'updated': U, 'no_ohlc': G, 'dry_run': 0}scripts/backfill_real_ma_periods.py:599)。process 若中途拋例外會有 traceback + 非零 exit,這種情況才是唯一的失敗訊號。

步驟 3:MA 系列旗標(`ma_slope → ma_rally_reject → ma_pullback_hold →

ma5_ma20_cross`,四支腳本依序各自跑一次)

python scripts/backfill_ma_slope.py
python scripts/backfill_ma_rally_reject.py
python scripts/backfill_ma_pullback_hold.py
python scripts/backfill_ma5_ma20_cross.py

四支腳本次序固定(每支都讀回步驟 2 剛寫入的 ma20/ma60 存量值,不是自己 重算 MA),且都不吃額外旗標(--ticker 單檔篩選、--dry-run--batch-commit-size 每支都有,預設 500——scripts/backfill_common.py:18 DEFAULT_BATCH_COMMIT_SIZE;沒有 --force,也沒有全域 ticker 範圍旗標)。

  • backfill_ma_slope.py:SQL 內嵌 o.adj_close AS closescripts/backfill_ma_slope.py:114)——這支自己重新從 stock_ohlc 算滾動平均(不是回填 MA 存量值),所以直接吃 adj 切換影響。冪等: 「recompute 後與既有值做 diff,只 UPDATE 有變化的列」 (module docstring 54-57 行),完成的 ticker 二次執行零 UPDATE。 完成判準明確:main() 檢查 run() 回傳的失敗 ticker 清單,非空即 sys.exit(1)scripts/backfill_ma_slope.py:453-454)——這支的 exit code 可信。
  • backfill_ma_rally_reject.py:SQL 讀 o.high, o.closeraw,未 改 adj——scripts/backfill_ma_rally_reject.py:112-127,本 session 用 git diff dev...HEAD 確認此檔在本 PR 完全未變動)。這支之所以仍列在重算 鏈裡,是因為它讀回的 ma20/ma60 存量值(步驟 2 剛寫入,現在是 adj 基準)與觸價/收盤比較的 high/close(raw 基準)—— 這是 code 現況的 raw/adj 混基準讀取,與其手足 ma_pullback_hold.py(下面,adj_low/ adj_close)不對稱;scheduler.py:936 的 D12 鏈也把它排在同一位置,說明 這是既有設計而非本次遺漏,但本 runbook 如實記錄,不代它宣稱「已改 adj」。完成判準:main() 檢查失敗清單、不 sys.exitscripts/backfill_ma_rally_reject.py:302-313,與 ma_slope.py/ ma5_ma20_cross.py不同)——process exit code 恒為 0(除非未捕捉例外); 唯一訊號是 log 行 Done. N rows updated 及若有失敗會另印 ERROR ... tickers failed and need a re-run: <list>必須用 grep 這行 ERROR,不能只看 exit code。冪等機制同 ma_slope.py(diff 後只寫變化列)。
  • backfill_ma_pullback_hold.py:SQL 讀 o.adj_low AS low, o.adj_close AS closescripts/backfill_ma_pullback_hold.py:129)——與 ma_rally_reject.py 不同,這支切 adj。完成判準與冪同上一支:main() 同樣不檢查失敗、不 sys.exitscripts/backfill_ma_pullback_hold.py: 330-345)——同樣要 grep ERROR 行。
  • backfill_ma5_ma20_cross.py不直接讀 stock_ohlc——輸入只有 daily_technical_snapshot 存量的 ma5/ma20(module docstring 18-21 行:「single source, not recomputed」),basis 中立,靠上游步驟 2 已經是 adj 值來間接吃到切換影響。完成判準明確:main() 檢查失敗清單, 非空即 sys.exit(1)scripts/backfill_ma5_ma20_cross.py:259)。

步驟 4:backfill_volume_price_only.py(量價指標,價 adj、量 raw)

python scripts/backfill_volume_price_only.py \
  --start-date <stock_ohlc 全史最早日期> \
  --date <今天或最新交易日> \
  --include-inactive \
  --apply
  • --start-date/--date 為必填參數argparse required=Truescripts/backfill_volume_price_only.py:875-876)——沒有「全史」預設 值,執行前必須先查詢實際最早日期:
SELECT MIN(timestamp)::date FROM stock_ohlc WHERE market = 'taiwan';

(D12 鏈不含這支腳本、不能假設順帶涵蓋——計畫 §3 步驟 4 原文;本步驟必須 獨立跑滿全史範圍。) - --apply 必帶:不帶 --apply 時腳本只計算、印出 DRY-RUN complete 摘要,完全不寫入scripts/backfill_volume_price_only.py:829, 883,907)——這與其他腳本「預設寫入、--dry-run 才是唯讀」的方向相反, 容易誤用,特別標注。 - --include-inactive:預設只處理 stocks.is_active=true 的 ticker (scripts/backfill_volume_price_only.py:143);全市場一次性重算若要涵蓋 已下市 ticker,需要顯式帶這個旗標——是否涵蓋屬操作者當下決策,本文件不 替決策,只指出旗標存在且預設排除。 - basis:SQL 內嵌 o.adj_open/adj_high/adj_low/adj_close AS open/high/low/closescripts/backfill_volume_price_only.py:221-224), volume 維持 raw;無 --basis 旗標。 - 冪等性:每次都無條件重算並 UPDATE(_apply_records 直接 UPDATE ... FROM vp_backfill_stagescripts/ backfill_volume_price_only.py:593-624)——沒有「已完成列跳過」邏輯, 純函數式重跑等價於重算。主迴圈無 try/exceptscripts/backfill_volume_price_only.py:766-827 _run()for idx, ticker in enumerate(tickers, 1) 沒有包 try)——任一 ticker 未捕捉例外會讓整個 process 崩潰;沒有 --ticker 之外的批次續跑機制, 中斷後只能整批重跑同一條指令(冪等安全但浪費,或改用 --ticker 逐檔 補跑剩餘字母序在後的 ticker)。 - 完成判準:無 sys.exit 邏輯(scripts/backfill_volume_price_only.py: 887-908 main()),process 未拋例外即視為完成;訊號是最終一行 APPLY complete: computed=N updated=U elapsed=...s

步驟 5:backfill_forward_returns.py(forward returns → forward_return_label

python -m scripts.backfill_forward_returns --include-inactive
  • --include-inactive:預設只處理 stocks.is_active=truescripts/backfill_forward_returns.py:186-201);docstring 明寫「Set True for one-shot historical backfills that want to populate forward returns for delisted tickers」——本次全市場一次性重算屬於這個用例, 建議帶上(是否涵蓋已下市 ticker 仍是操作者決策,理由同步驟 4)。
  • basis:SQL 內嵌 adj_close AS closescripts/backfill_forward_returns.py:239);無 --basis 旗標。
  • 冪等性:UPSERT(ON CONFLICT (ticker, trading_date, interval) DO UPDATEscripts/backfill_forward_returns.py:312-319),docstring 明寫「Idempotent: re-running over the same range writes the same values」。有 per-ticker session 隔離 + try/exceptscripts/backfill_forward_returns.py:453-468)——單一 ticker失敗只計入 report.errors、不中斷其餘 ticker,可用 --tickers <失敗清單> 精準 重跑失敗子集。
  • 完成判準sys.exit(0 if not report.errors else 1)scripts/backfill_forward_returns.py:502-520)——這支的 exit code 可信--include-inactive 若省略,日誌摘要行 Backfill complete: %d tickers, %d rows, %d errors 中的 tickers 數會 只涵蓋 active 名單,需與 §3 完成確認 SQL 對照。

步驟 6:market_regime.py compute(^TWII 市場狀態)

python market_regime.py compute
python market_regime.py verify
  • 單一 index ticker(預設讀 config/regime.yamlbase_ticker,通常 ^TWIIscripts/market_regime.py:1332 ticker = args.ticker or config["base_ticker"]),不需要 --ticker 分片。
  • basis_load_regime_ohlc 內嵌 adj_open/adj_high/adj_low/adj_close AS open/high/low/closescripts/market_regime.py:1183-1186);無 --basis 旗標。
  • 冪等性save_regime_daily 為逐列 UPSERT(scripts/ market_regime.py:962-1035);save_regime_segmentsDELETE ... WHERE ticker = :ticker 後整批 INSERTscripts/market_regime.py:1038-1067),同一 transaction 內完成, 完整重跑等於完整替換該 ticker 的 segment 表——冪等且原子。
  • 完成判準compute 子命令的 main()db_url 缺失才會 sys.exit(1)scripts/market_regime.py:1326-1329),計算流程本身 沒有失敗即中止的 exit code——完成訊號是最後一段逐 segment 印出的摘要 (scripts/market_regime.py:1284-1292)加上 Saved N daily rows / Saved M segments 兩行。verify 子命令是內建的收尾檢查工具 (run_verificationscripts/market_regime.py:1074),log 印 === All verification checks PASSED ====== Some verification checks FAILED ===——後者不是 process 失敗 (沒有對應 sys.exit),必須看這行文字本身。

3. 跨步驟紀律與完成確認 SQL

3.1 分片限制(如實記錄,不擬制)

除步驟 1 的 --workers(單容器內 process pool)外,步驟 2–6 沒有任何 --shard / ticker 範圍旗標可用(本 session 對 scripts/ backfill_real_ma_periods.pybackfill_ma_slope.pybackfill_ma_rally_reject.pybackfill_ma_pullback_hold.pybackfill_ma5_ma20_cross.pybackfill_volume_price_only.pybackfill_forward_returns.pymarket_regime.py 逐一 grep --shard 均無結果)。這與 PR-B backfill_adj_ohlc.py--shard I/N 原生分片不同 ——本文件不擬制一個腳本不支援的分片機制。若要縮短總時長,唯一手段是 §4 試跑量測後,視實測時長決定是否需要另外開票替步驟 2–6 補 --shard/ticker 範圍旗標(屬程式碼變更,不在本 runbook 範圍)。

3.2 跨步驟不可交錯並行

計畫明訂:分片並行僅限同一步驟內;下一步開跑前必須確認上一步驟全部 ticker 已完整寫入。理由最直接的例子——步驟 4(volume_price_only)的 vp_* 旗標優先讀 daily_technical_snapshot 已持久化的 ma5/ma20/donchian_*_prefer_indicatorscripts/ backfill_volume_price_only.py:126-131),這些欄位由步驟 1寫入 (經 INDICATOR_TO_SNAPSHOT_COLUMN 映射)——若步驟 4 在步驟 1 尚未 100% 完成時搶跑,會把混基準(部分 ticker 已 adj、部分還是舊 raw)的 ma5/ma20 持久化進 turnover_pct/vp_* 欄位,且沒有機制事後偵測。

3.3 每步完成確認 SQL

以下以 UTC 時間戳 <STEP_START>(該步驟 docker run 發起前記錄的 date -u +%Y-%m-%dT%H:%M:%SZ)為界,比對「這次有被寫入的 ticker 數」與 「該表的 ticker 全集」。全集查詢因步驟而異(見各列備註)——不得省略備註 直接套用同一條全集 SQL。

步驟 目標表 本次寫入數 Ticker 全集(備註)
1 daily_technical_snapshot SELECT COUNT(DISTINCT ticker) FROM daily_technical_snapshot WHERE interval='1d' AND updated_at >= '<STEP_START>' SELECT COUNT(DISTINCT ticker) FROM daily_technical_snapshot WHERE interval='1d'_list_tickersis_active 篩選——scripts/backfill_full_history_indicators.py:391-394
2 同上 同上寫入查詢 同上(_list_tickers_to_backfill 亦無 is_active 篩選)
3(四支合計) 同上 同上寫入查詢(四支腳本共用同一批 updated_at,可用同一條) 同上
4 同上 同上寫入查詢,但需再 AND ticker IN (SELECT ticker FROM stocks WHERE market='taiwan' AND is_active=true) 視是否帶 --include-inactive 調整篩選 SELECT COUNT(DISTINCT ticker) FROM stocks WHERE market='taiwan' AND is_active=true(未帶 --include-inactive 時)或含已下市(帶時,_load_tickersscripts/backfill_volume_price_only.py:142-171
5 forward_return_label SELECT COUNT(DISTINCT ticker) FROM forward_return_label WHERE interval='1d'(該表無 updated_at,改用整表計數比對執行前後差異) SELECT COUNT(*) FROM stocks WHERE market='taiwan'(帶 --include-inactive)或 ... AND is_active=true(不帶)
6 market_regime_daily / market_regime_segment SELECT COUNT(*) FROM market_regime_daily WHERE ticker='^TWII' 應等於 SELECT COUNT(*) FROM stock_ohlc WHERE ticker='^TWII'(單一 ticker,逐日對照) 同左

4. 時長估算方法(merge 後 3 檔試跑實測外推)

不得預填未實測數字——以下只給量測與外推的做法,具體秒數留待實際執行時 填入。

  1. 選 3 檔具代表性 ticker 試跑(例如:長史大型股如 2330.TW、issue 主角 1519.TW——已在 C4 等價測試中作為有事件股参照、任一短史或小型股補齊 第三檔),對步驟 1–6 各自--ticker 單檔模式跑一次並計時:
for step_cmd in \
  "backfill_full_history_indicators.py --workers 1 --ticker" \
  "backfill_real_ma_periods.py --force --ticker" \
  "backfill_ma_slope.py --ticker" \
  "backfill_ma_rally_reject.py --ticker" \
  "backfill_ma_pullback_hold.py --ticker" \
  "backfill_ma5_ma20_cross.py --ticker" \
; do
  for ticker in 2330.TW 1519.TW <第三檔>; do
    time python scripts/${step_cmd} "${ticker}"
  done
done

(步驟 4/5 的 --ticker 語義與上面相同;步驟 6 是單一 index ticker, 本身已是最小單位,直接整支計時即可,不需要另外抽樣。) 2. --workers 1 量測:步驟 1 的試跑刻意用 --workers 1(單 ticker 場景下多 worker 沒有意義),全市場正式跑時再用 §2 步驟 1 建議的 --workers 8——兩者不可直接按比例換算(process pool 啟動開銷 + DB 連線池爭用不是線性的),若要更準的估時,額外再跑一次 --workers 8 --limit <N>scripts/backfill_full_history_indicators.py: 902-905 --limit 是限制前 N 個字母序 ticker 的 benchmark 用旗標)取得 多 ticker、多 worker 情境下的每 ticker 平均秒數。 3. 外推公式(每步驟各自套用):

T_pilot_avg = T_pilot_total_seconds / 3        # 該步驟 3 檔試跑的平均秒數
N_universe  = <§3.3 該步驟對應的 Ticker 全集數>   # 執行當下查 DB 取得
T_full_est ≈ T_pilot_avg × N_universe            # 該步驟全市場估時下限

六個步驟的估時各自獨立記錄,不加總成單一「全鏈時長」宣稱——因為 §3.2 規定跨步驟不可交錯,實際總時長是六個 T_full_est 的序列和,但每個 T_full_est 本身只是「基於 3 檔外推的下限」,不是保證值(同 PR-B runbook §5 對 yfinance 節流下限的警語:真實耗時只會更長,實際請以 log 節奏推算進度)。 4. 量測與外推結果連同起訖時間戳一併附回 issue #1253(比照 PR-B runbook dry-run 報告核可流程),供後續排程規劃參考。

5. 6b:rule_regeneration.py 重跑+策略型錄匯入+signal_backtest 代表性案例重跑

5.1 phase1 parquet 重生(rule_regeneration 的間接輸入之一,recalibrate/

rule_discovery 的直接輸入)

scripts/phase1_prepare_data.pystock_ohlcbasis=OHLC_BASIS_ADJscripts/phase1_prepare_data.py:114-116)算出的 adj 基準收盤價,額外存成 indicator_close 欄位(scripts/phase1_prepare_data.py:126-130)寫進 backtest_results/phase1/analysis_snapshots.parquet。這是本 PR 新增的 fail-loud 條件scripts/recalibrate_signals.py_swap_to_ indicator_close(:44-61)在載入 parquet 缺少 indicator_close 欄位時直接 raise ValueError——舊(PR-C 之前產生)的 parquet 沒有這欄,必須先重生:

python -m scripts.phase1_prepare_data
  • --start/--end 預設值是固定日期,不是「全史」BacktestConfig.full_start = date(2024, 2, 2)full_end = date(2026, 2, 24)backtest/config.py:25-26)——不帶旗標 沿用這個既有生產窗口;若要擴大範圍,操作者需明確指定 --start/--end(本文件不預填擴大後的日期,屬另一項需核可的決策)。
  • scripts/rule_discovery.pyPARQUET_PATH 預設同一份 parquet, scripts/rule_discovery.py:40也有同名同款的硬性 fail-loud guard—— _swap_to_indicator_closescripts/rule_discovery.py:50-68),在 _build_v3_conditions(:71-74,由 main() :594 呼叫)前強制套用,缺 indicator_close 欄位一樣 raise ValueError,訊息同樣指向重生 phase1 parquet。
  • scripts/conflict_analysis.py 的同名函式(:41-59)防護力道不同: 只是 logger.warning 後 fallback 回 raw close 繼續跑,不會中止 ——docstring 說明原因是這支工具的輸入是操作者任意指定的 parquet 目錄 (--parquet-dir),不保證就是 phase1_prepare_data.py 的輸出,所以只能 警告不能硬擋。若要用 conflict_analysis.py 覆核本次重算結果,執行前務必 先確認餵給它的 parquet 目錄確實是 §5.1 剛重生的那份,因為它自己不會替你 把關。
  • 重生後可選跑一次 smoke check 確認 fail-loud 條件不再觸發(不等於執行 正式 recalibration——後者會改動 strategy_config.json 的命中率權重, 屬另一項金融規則變更,需要獨立回測與核可,不在本 runbook 範圍內):
python -m scripts.recalibrate_signals --dry-run

5.2 rule_regeneration.py 重跑

python scripts/rule_regeneration.py \
  --output-dir /app/backtest/scoring/
  • DATABASE_URL 優先(issue #1253 PR-C C5 修復):_get_engine()scripts/rule_regeneration.py:173-186)現在優先讀 DATABASE_URL 環境變數(asyncpg 前綴自動轉 psycopg2),只有在該變數缺失時才降級用字面值 postgresql://cortex:cortex_password@postgres:5432/cortex_investment# fallback only 註明)——§1 的 -e DATABASE_URL="${DATABASE_URL}" 對這支腳本現在會生效,不需要再另外核對 live DB_PASSWORD 是否等於字面值 cortex_password
  • basisload_ohlcv_closescripts/rule_regeneration.py:263-295) 內嵌 adj_close AS close,且 §5.1 的 indicator_close 欄位在 main() 中 被 merge 進 ta_dfbuild_ta_condition_masks 使用 (scripts/rule_regeneration.py:1195-1205 註解明寫「the ma/bb/support columns it compares against are adj post PR-C recalc, so the comparison basis must match」)——這支同時依賴 §5.1 的 parquet 重生直接的 stock_ohlc adj 讀取,兩者缺一都是混基準。
  • --start-date/--end-date 預設值同樣是固定日期2024-08-27 ~ 2026-03-05scripts/rule_regeneration.py:1163-1164), 不是全史;沿用生產既有窗口就不帶旗標,擴大範圍屬另一項需核可的決策 (同 §5.1)。
  • 這支腳本只產生 JSON 檔strategy_rules.json + 報告,寫進 --output-dirscripts/rule_regeneration.py:1405-1415),不直接寫 DB——不會馬上反映到 strategy_rule_catalog

5.3 匯入 strategy_rule_catalog

python scripts/import_rule_catalog_unified.py
  • backend/backtest/scoring/strategy_rules.jsonRULES_PATHscripts/import_rule_catalog_unified.py:55)——與 §5.2 --output-dir 預設值 /app/backtest/scoring/(容器內 /app = backend)是同一份 檔案,本 session 已核對路徑一致。
  • 這支正常讀 DATABASE_URL 環境變數scripts/ import_rule_catalog_unified.py:385)——與 §5.2 的硬編連線不同,不要 混淆兩者的連線設定方式。
  • 冪等性:對整份 ruleset 檔案算 SHA-256,未變動時跳過完整 upsert (module docstring:「Uses a SHA-256 hash of the ruleset file to skip the full upsert when the file has not changed since the last import」) ——safe to re-run。
  • scheduler.py:2990-2992 顯示 scheduler 本身也會呼叫 import_unified_catalog——若 §0 已停 scheduler,本步驟必須手動執行才 會即時反映;恢復 scheduler 後之後的排程會再次呼叫(冪等,不會出錯,只是 正常的例行匯入)。
  • 完成判準sys.exit(1) 只在 DATABASE_URL 缺失時觸發 (scripts/import_rule_catalog_unified.py:386-388);正常匯入完成後印 Import result: {stats},需檢視印出的 stats 內容確認匯入筆數符合預期 (非零、與 §5.2 產出的 rule 數量級相符)。
  • 確認 SQL
SELECT COUNT(*), MAX(updated_at) FROM strategy_rule_catalog;

updated_at 只在內容真的變動時才被兩個寫入端 bump (database/models.py:2656-2659 註解),比對此欄位可判斷本次匯入是否 真的落了新值,而不是被 SHA-256 判定「未變動」而整批跳過。

5.4 signal_backtest 代表性案例重跑

python -m scripts.signal_backtest_service \
  --tickers 2330.TW,1519.TW,<其餘代表性 ticker> \
  --domain technical \
  --test-days 180
  • basis_load_pricesscripts/signal_backtest_service.py:817-863) 內嵌 adj_close AS close,docstring 明寫與 scripts/signal_backtest.py 共用同一顆 compute_forward_hits,兩邊必須讀同一基準(:823-828)。
  • 過渡態聲明(計畫 §3 步驟 6b 原文,不由本 runbook 重新定義)signal_backtest_run/event/summary 既有列是「執行當下」的歷史產物, 切換後新跑的 run 自然是 adj 基準,舊 run 不回溯改寫——本步驟的目的 是刷新 /accuracy 端點主要看的統計,不是清除歷史 run。
  • --tickers 選幾檔具代表性的(含至少一檔有事件股,如 1519.TW,理由同 §4 試跑選檔)即可,不需要覆蓋全市場——這一步是驗證用途,不是全史重算的 一環。

5.5 ath_price_adj / high_52w_price_adj(migration 124 新欄位)為何不在 §2 的 6 步重算鏈內

本 session 對 scripts/backfill_full_history_indicators.pyVALUE_COLUMNS(源自 database.indicator_column_map. INDICATOR_TO_SNAPSHOT_COLUMN)逐一核對,沒有 ath_price_adj/high_52w_price_adj;這兩欄的唯一逐日寫入點是 scripts/daily_analysis_snapshot.py逐日前向 pipeline_adjusted_high_columns,:1582-1586;_load_adjusted_highs 類函式對每個 (ticker, trading_date) 各查一次 DB,屬逐日增量寫入設計,不是全史批次 寫入的形狀)。§2 的 6 步重算鏈跑完後,歷史列的這兩欄仍是 NULL——這個 缺口由 §5.6 的 backfill_ath_adj_history.py(issue #1253 PR-C C5)補齊, 執行順序見 §5.6 開頭。

5.6 6c:backfill_ath_adj_history.pyath_price_adj / high_52w_price_adj 全史回填)

接續 §5.5:本步驟是 issue #1253 PR-C C5 新增的一次性全史回填腳本,補齊 §2 的 6 步重算鏈不會寫的 ath_price_adj/high_52w_price_adj 兩欄。建議排在 §2 之後、§5.1–§5.5 之前或之後皆可(本腳本只讀 stock_ohlc.adj_highdaily_technical_snapshot 既有列,不依賴 §5.1–§5.5 任何一步的輸出):

python scripts/backfill_ath_adj_history.py --dry-run
python scripts/backfill_ath_adj_history.py
  • --db-url 預設 DATABASE_URL 環境變數scripts/ backfill_ath_adj_history.py main()),與 §1 的 -e DATABASE_URL="${DATABASE_URL}" 慣例相容;兩者皆缺會直接 SystemExit
  • 範圍:市場硬編 taiwanMARKET_SCOPE),對應 「台股歷史列」;--ticker 可限定單檔。
  • 語義ath_price_adj = 該 ticker 截至該 trading_date(含當日)的 stock_ohlc.adj_high running MAX;high_52w_price_adj = 過去 365 個日曆天(含當日、含邊界日)的 adj_high MAX——與 scripts/ daily_analysis_snapshot.py_query_adjusted_historical_highs 逐字對齊(同一份 PG 測試 tests/integration/test_backfill_ath_adj_history_pg.py 對每個種子日期 直接呼叫該函式做 parity 斷言)。adj_high 為 NULL 的 OHLC 列不參與聚合 (MAX() 忽略 NULL,不需要額外剔除邏輯);daily_technical_snapshot 某列若在其 trading_date 找不到對應 stock_ohlc 列,該列維持 NULL, 不會用 raw ath_price/high_52w_price 頂替。
  • 冪等性:diff 後只 UPDATE 有變化的列(IS DISTINCT FROM gate 內嵌在 UPDATE 本身),完成的 ticker 二次執行零 UPDATE;批次 commit(預設 --batch-commit-size 500,同 scripts/backfill_common.py DEFAULT_BATCH_COMMIT_SIZE)避免單一 transaction 鎖全表。
  • 完成判準:main() 對失敗 ticker 清單不 sys.exit(同 backfill_ma_rally_reject.py/backfill_ma_pullback_hold.py 的模式)—— 完成訊號是 log 行 Done. N rows updated,若有失敗會另印 ERROR ... tickers failed and need a re-run: <list>,需 grep 這行 ERROR,不能只看 exit code。
  • 確認 SQL(跑完後應為 0——任一列自己的 adj_high 非 NULL,其 ath_price_adj/high_52w_price_adj 就不可能是 NULL):
SELECT COUNT(*)
FROM daily_technical_snapshot t
JOIN stocks s ON s.ticker = t.ticker
JOIN stock_ohlc o
  ON o.ticker = t.ticker
 AND o.market = s.market
 AND o.timestamp::date = t.trading_date
WHERE s.market = 'taiwan'
  AND t.interval = '1d'
  AND o.adj_high IS NOT NULL
  AND (t.ath_price_adj IS NULL OR t.high_52w_price_adj IS NULL);

5.7 6d:backfill_support_dual_history.py(支撐族雙基準全史回填)

執行插槽:必須排在 §2 的 6 步重算鏈全部完成之後(§5.1–§5.6 之前或之後皆 可,本腳本不依賴那幾步的輸出)。這不是「同一個維護窗就好」的鬆散順序,而是 真依賴:本腳本 adj 半的 atr_stop_distance 重演 compute_riskatr * 2.0,讀的是 daily_technical_snapshot.atr_value 存量值,該欄由 §2 步驟 1 重算;提前跑會把停損距離釘死在切換前的舊 ATR 上。其餘欄位只依賴 PR-B 的 raw/adj 欄本身。

python scripts/backfill_support_dual_history.py --dry-run
python scripts/backfill_support_dual_history.py
  • 為什麼需要這支:支撐族欄位不在 database/indicator_column_map.INDICATOR_TO_SNAPSHOT_COLUMN,所以 §2 的 6 步鏈跑完後完全不會改寫它們——歷史列的 major_support 等欄即使 在 C7 之後仍是 PR-C 之前的舊 raw 計算值(「既有欄=adj」只對切換後新產生的 快照成立)。這與 §5.5 描述的 ath_price_adj 是同一類缺口。
  • 兩個半邊(一次查詢、兩條 UPDATE):
  • adj 半(修 PR-C 缺口,寫既有欄 11 個)major_supportmajor_resistancelow_20d_minlow_60d_min、四個 distance_to_support_*,加上 compute_risk 的三個輸出—— structure_stop(= adj major_support)、atr_stop_distance (= atr_value × 2)、invalidation_level (= COALESCE(structure_stop, adj_close − atr_stop_distance))。 風險三欄一併重寫是為了避免修完變成「新的不一致」(停損指向一個已經不存在 的支撐位,比一致地舊更糟)。
  • raw 半(migration 125 新欄 9 個)major_support_raw .. structure_stop_raw沒有 atr_stop_distance_raw / invalidation_level_raw——ATR 屬指標、指標恆 adj。
  • 窗口語義:與 scripts/daily_analysis_snapshot.pycompute_support_resistance 逐字對齊(近 20 根收盤 min/max、20/60 根盤中 低點 min,未滿 20 根時盤中低點維持 NULL、20–60 根之間 60 日低點退回 20 日 低點);距離欄的分子分母必為同一基準。PG 測試 tests/integration/test_backfill_support_dual_history_pg.py 對每個種子日期 直接呼叫該函式做 parity 斷言。
  • 範圍:市場硬編 taiwanMARKET_SCOPE);--ticker 可限定單檔。
  • NULL adj 剔除不頂替:某個 bar 的 adj_* 任一為 NULL 時,該列只跳過 adj 半(既有值原封不動,不會被清成 NULL、也不會用 raw 值頂替),raw 半 照常寫入。daily_technical_snapshot 某列若在其 trading_date 找不到對應 stock_ohlc 列,兩個半邊都不動。
  • 冪等性:diff 後只 UPDATE 有變化的列(IS DISTINCT FROM gate 內嵌在兩條 UPDATE 各自的 WHERE),完成的 ticker 二次執行零 UPDATE;批次 commit (預設 --batch-commit-size 500,同 scripts/backfill_common.py DEFAULT_BATCH_COMMIT_SIZE)。中斷續跑:直接重跑同一條指令。
  • 完成判準:main() 對失敗 ticker 清單不 sys.exit(同 §5.6 的模式)—— 完成訊號是 log 行 Done. N rows updated,若有失敗會另印 ERROR ... tickers failed and need a re-run: <list>,需 grep 這行 ERROR, 不能只看 exit code。
  • 確認 SQL(跑完後應為 0——任一列自己的 raw bar 存在,其 major_support_raw 就不可能是 NULL):
SELECT COUNT(*)
FROM daily_technical_snapshot t
JOIN stocks s ON s.ticker = t.ticker
JOIN stock_ohlc o
  ON o.ticker = t.ticker
 AND o.market = s.market
 AND o.timestamp::date = t.trading_date
WHERE s.market = 'taiwan'
  AND t.interval = '1d'
  AND t.major_support_raw IS NULL;

6. 驗證抽樣

6.1 無事件股不變性(沿用 C4 測試同款斷言邏輯)

C4 等價測試(tests/integration/test_basis_switch_equivalence_pg.py)用 下面這條 SQL 選出「全史 raw==adj」的股票(不得dividend_events 表存在與否當代理——該表史料地板 2026-06-17、來源與 yfinance 推導的 adj 值 完全獨立,代理會有假陽性/假陰性,測試檔 24-31 行已明文禁止並在測試中證明 排除力):

SELECT ticker
FROM stock_ohlc
WHERE market = 'taiwan'
GROUP BY ticker
HAVING COUNT(*) = COUNT(*) FILTER (
    WHERE adj_open = open AND adj_high = high
      AND adj_low = low AND adj_close = close
)
LIMIT 20;

對抽出的 N 檔股票,人工核對 §2 步驟 1/2/3 重算後的 daily_technical_snapshot 指標值,與重算前的舊值逐列比對應完全相同(這批 股票的 adj 序列與 raw 序列本來就逐 bit 相同,指標值不該因為切換基準而變 動——任何差異代表重算或 loader 出了問題,需要停下追查,不可視為預期內的 「重算後正常有變化」)。

6.2 有事件股方向抽測

1519.TW(issue 主角):切換並重算後,比對其 ma5/ma20/ma20_slope/ ma60_slope 等在歷史除息日附近的走勢,應不再出現 raw 基準下的單日斷崖 (C4 測試 test_event_ticker_adj_basis_removes_ma_cliff_raw_basis_keeps_it 已在合成資料上證明這個方向性;本步驟是拿真實 1519.TW 資料做人工複核, 不是重跑該單元測試)。

6.3 SES 過渡態聲明(#1259,如實轉述既有 follow-up,不重新定義範圍)

adj 全史重寫後 SES(signal/event/story)相關欄位為 stale,是已知 follow-up #1259不 在本 PR-C 的重算範圍內(計畫 §0「Out of scope」、§3 步驟 7 明文: 「daily_analysis_snapshot 完整 pipeline 不回溯重跑——歷史快照的訊息/ 型態/SES 相關欄位維持舊值」)。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 落在舊基準上,等待獨立的 SESI 全史重生 PR(計畫 §4 步驟 4)。

6.4 adj NULL 剔除警報監控(整個 §2/§5 執行期間持續進行,不是事後單次檢查)

§2/§5 每一支腳本的 adj 讀取都會呼叫 report_adj_null_exclusions_for_rows /report_adj_null_exclusionsdatabase/crud.py:129-184)——剔除率 (excluded_rows/candidate_rows超過 indicators. adj_null_exclusion_alarm_pctconfig/core.yaml,值 10.0——2026-08-08 依 B7 驗證 (e) 逐檔統計 p99 7.62% 上取整定案並經使用者核可)時記 logger.error; 未超過但 >0 時記 logger.warningdatabase/crud.py:144-163)——兩種 情況都不會讓腳本中止或非零 exit,純粹是 log 層級的告警,不是 §2 各步驟 完成判準的一部分。執行期間應另開一個 log 監看視窗持續 grep:

docker logs -f <本次執行的容器> 2>&1 | grep "adj basis excluded"

出現 ERROR 等級的這行,代表某個 ticker 的剔除率超過門檻,需要另外判斷是 raw 重建(PR-B)遺留的資料缺口,還是本次重算引入的新問題;不可以只因為 process exit code 是 0/腳本正常跑完就假設沒有這類告警。

  1. 確認 §2 全部 6 步驟都已完整跑完(§3.3 的完成確認 SQL 逐步核對過, §2 每步的完成判準——log 訊息或 exit code,依各步驟實際可信的訊號—— 都通過),且 §5 的 6b/代表性案例重跑也已完成。
  2. 確認沒有還在跑的重算容器
docker ps --filter "ancestor=investment-agent-agent-api"

本文件所有臨時容器一律 --rm,正常結束後不會殘留;若這條指令還列出 東西,代表還有批次在跑,不可以在這個狀態下恢復 scheduler——恢復後 scheduler 的排程任務會跟手動還在跑的容器打架,且沒有互斥機制。 3. 恢復 scheduler:

docker restart cortex-scheduler
  1. 真實環境驗證:下一個 scheduler 排程窗口跑過後,用 §3.3 的完成確認 SQL 之一(例如步驟 5 的 forward_return_label 計數)確認新產生的每日 增量列使用的也是 adj 基準(可用當天新寫入的列與前一天已存在的(本次 重算過的)列做同款指標公式的連續性檢查,不應該在切換日出現斷點)。
  2. docker ps -a 清點本次自己起的 --rm 容器是否確實清乾淨(--rm 容器結束即自動移除,這裡是最後一道確認,不是額外清理動作)。

8. 回滾

8.1 loader basis 退回 raw(config/呼叫點層級)

計畫 §7 訂的回滾路徑:「loader basis 參數退回 "raw" 一行 config/呼叫點」 ——即把 §2 各步驟腳本內嵌的 basis=OHLC_BASIS_ADJ / adj_open AS open 等改回 raw 讀取,這是程式碼變更(走小 PR,同計畫 §7 的回滾定義),不是 單純下指令可以完成的操作;本 runbook 不代為決定要不要走這條回滾路徑,只 記錄其存在與性質。

8.2 反向重跑同鏈

若 §8.1 的程式碼回滾已上線(或決定暫時手動指定 raw 讀取跑一次驗證), 反向重跑就是把 §2 的六個步驟依原順序(1→2→3→4→5→6)用退回 raw 後的 程式碼再跑一次——重算鏈本身冪等(各步驟冪等性見 §2 逐條說明),對同一批 daily_technical_snapshot/forward_return_label/market_regime_*/ strategy_rule_catalog 列再跑一次會把值改回 raw 基準結果,不需要額外的 清除步驟。

8.3 Migration 124 回滾

如遷移本身需要撤銷(獨立於 §8.1/§8.2 的資料層回滾,只影響 schema):

ALTER TABLE daily_technical_snapshot DROP COLUMN IF EXISTS high_52w_price_adj;
ALTER TABLE daily_technical_snapshot DROP COLUMN IF EXISTS ath_price_adj;

(原樣取自 database/migrations/124_ath_adj_columns.sql 模組註解的 Rollback 段落。)

8.4 Migration 125 回滾

database/migrations/125_support_raw_columns.sql 模組註解的 Rollback 段落列出 9 條 DROP COLUMN IF EXISTS(原樣取用)。前置動作MAIN_TABLE_COLUMNS 仍含這 9 欄,欄位一旦 drop、cortex-scheduler 的每日快照 INSERT 就會整條失敗 (理由同 §0.3b)——drop 之前必須先把程式碼回滾到不含這 9 欄的版本並重啟 scheduler,順序不可顛倒。