Runbook:台股指標/回測全史重算(issue #1253 PR-C)
對應 PR-C(#1253 方向 3)在三個中心 loader(
_load_ohlc_series/_load_ohlc_series_batch、StockCRUD.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_snapshot加ath_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. 前置狀態(執行前先逐條確認)
- PR-C 已 merge,主 repo
git pull(cd <主 repo>/investment-agent && git pull,或所在目錄的等效 pull)。 - Gate:B7 驗證報告 (a)–(f) 全綠(adj 基準乾淨——
verify_ohlc_basis_repair.py六個唯讀檢查,見 PR-B runbook §6)。B7 未綠之前,本文件任何一步都不可執行: adj 欄本身有污染時,全史重算只是把污染從「未使用」變成「已使用並持久化進 衍生表」,比不重算更難清。 - 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.py 的 MAIN_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-940:full_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-network(docker-compose.yml專案名investment-agent前綴出的預設網路,已用docker network ls核對存在)。 - DATABASE_URL 從
cortex-apienv 帶入(不重新輸入密碼):
cd investment-agent
DATABASE_URL=$(docker exec cortex-api printenv DATABASE_URL)
- Mount 唯讀 code:
agent-api自己的 compose 條目是${BACKEND_PATH:-./backend}:/app:delegated(可寫、給--reload用);本文件的一次性docker run --rm容器一律用唯讀掛載,確保跑的是 §0.1git 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.yml 的 agent-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_ADJ(scripts/backfill_full_history_indicators.py:683),沒有--basis旗標——本腳本永遠讀 adj,不需要額外指定。 - 並行:唯一有原生平行機制的一步——
--workers N(1 <= N <= 8,MAX_WORKERS,scripts/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 close(scripts/ backfill_real_ma_periods.py:252,314),無--basis旗標。 - 冪等性:
--force模式下沒有跳過已完成 ticker 的機制(force=True→skip_already_populated=False),每次整批重算全部 UPDATE——冪等體現在 「重算結果每次相同」而非「已完成的省略」。沒有per-ticker try/except(scripts/backfill_real_ma_periods.py:571-596backfill_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 close(scripts/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.close(raw,未 改 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.exit(scripts/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 close(scripts/backfill_ma_pullback_hold.py:129)——與ma_rally_reject.py不同,這支有切 adj。完成判準與冪同上一支:main() 同樣不檢查失敗、不sys.exit(scripts/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=True,scripts/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/close(scripts/backfill_volume_price_only.py:221-224),
volume 維持 raw;無 --basis 旗標。
- 冪等性:每次都無條件重算並 UPDATE(_apply_records 直接
UPDATE ... FROM vp_backfill_stage,scripts/
backfill_volume_price_only.py:593-624)——沒有「已完成列跳過」邏輯,
純函數式重跑等價於重算。主迴圈無 try/except
(scripts/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=true(scripts/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 close(scripts/backfill_forward_returns.py:239);無--basis旗標。 - 冪等性:UPSERT(
ON CONFLICT (ticker, trading_date, interval) DO UPDATE,scripts/backfill_forward_returns.py:312-319),docstring 明寫「Idempotent: re-running over the same range writes the same values」。有 per-ticker session 隔離 + try/except (scripts/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.yaml的base_ticker,通常^TWII;scripts/market_regime.py:1332ticker = args.ticker or config["base_ticker"]),不需要--ticker分片。 - basis:
_load_regime_ohlc內嵌adj_open/adj_high/adj_low/adj_close AS open/high/low/close(scripts/market_regime.py:1183-1186);無--basis旗標。 - 冪等性:
save_regime_daily為逐列 UPSERT(scripts/ market_regime.py:962-1035);save_regime_segments為DELETE ... WHERE ticker = :ticker後整批INSERT(scripts/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_verification,scripts/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.py、backfill_ma_slope.py、
backfill_ma_rally_reject.py、backfill_ma_pullback_hold.py、
backfill_ma5_ma20_cross.py、backfill_volume_price_only.py、
backfill_forward_returns.py、market_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_indicator,scripts/
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_tickers 無 is_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_tickers,scripts/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 檔試跑實測外推)
不得預填未實測數字——以下只給量測與外推的做法,具體秒數留待實際執行時 填入。
- 選 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.py 讀 stock_ohlc(basis=OHLC_BASIS_ADJ,
scripts/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.py(PARQUET_PATH預設同一份 parquet,scripts/rule_discovery.py:40)也有同名同款的硬性 fail-loud guard——_swap_to_indicator_close(scripts/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}"對這支腳本現在會生效,不需要再另外核對 liveDB_PASSWORD是否等於字面值cortex_password。 - basis:
load_ohlcv_close(scripts/rule_regeneration.py:263-295) 內嵌adj_close AS close,且 §5.1 的indicator_close欄位在 main() 中 被 merge 進ta_df供build_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_ohlcadj 讀取,兩者缺一都是混基準。 --start-date/--end-date預設值同樣是固定日期 (2024-08-27~2026-03-05,scripts/rule_regeneration.py:1163-1164), 不是全史;沿用生產既有窗口就不帶旗標,擴大範圍屬另一項需核可的決策 (同 §5.1)。- 這支腳本只產生 JSON 檔(
strategy_rules.json+ 報告,寫進--output-dir,scripts/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.json(RULES_PATH,scripts/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_prices(scripts/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.py 的
VALUE_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.py(ath_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_high 與
daily_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.pymain()),與 §1 的-e DATABASE_URL="${DATABASE_URL}"慣例相容;兩者皆缺會直接SystemExit。- 範圍:市場硬編
taiwan(MARKET_SCOPE),對應 「台股歷史列」;--ticker可限定單檔。 - 語義:
ath_price_adj= 該 ticker 截至該trading_date(含當日)的stock_ohlc.adj_highrunning MAX;high_52w_price_adj= 過去 365 個日曆天(含當日、含邊界日)的adj_highMAX——與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, 不會用 rawath_price/high_52w_price頂替。 - 冪等性:diff 後只 UPDATE 有變化的列(
IS DISTINCT FROMgate 內嵌在 UPDATE 本身),完成的 ticker 二次執行零 UPDATE;批次 commit(預設--batch-commit-size 500,同scripts/backfill_common.pyDEFAULT_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_risk 的
atr * 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_support、major_resistance、low_20d_min、low_60d_min、四個distance_to_support_*,加上compute_risk的三個輸出——structure_stop(= adjmajor_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.py的compute_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 斷言。 - 範圍:市場硬編
taiwan(MARKET_SCOPE);--ticker可限定單檔。 - NULL adj 剔除不頂替:某個 bar 的
adj_*任一為 NULL 時,該列只跳過 adj 半(既有值原封不動,不會被清成 NULL、也不會用 raw 值頂替),raw 半 照常寫入。daily_technical_snapshot某列若在其trading_date找不到對應stock_ohlc列,兩個半邊都不動。 - 冪等性:diff 後只 UPDATE 有變化的列(
IS DISTINCT FROMgate 內嵌在兩條 UPDATE 各自的 WHERE),完成的 ticker 二次執行零 UPDATE;批次 commit (預設--batch-commit-size 500,同scripts/backfill_common.pyDEFAULT_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_exclusions(database/crud.py:129-184)——剔除率
(excluded_rows/candidate_rows)超過 indicators.
adj_null_exclusion_alarm_pct(config/core.yaml,值 10.0——2026-08-08 依
B7 驗證 (e) 逐檔統計 p99 7.62% 上取整定案並經使用者核可)時記 logger.error;
未超過但 >0 時記 logger.warning(database/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/腳本正常跑完就假設沒有這類告警。
- 確認 §2 全部 6 步驟都已完整跑完(§3.3 的完成確認 SQL 逐步核對過, §2 每步的完成判準——log 訊息或 exit code,依各步驟實際可信的訊號—— 都通過),且 §5 的 6b/代表性案例重跑也已完成。
- 確認沒有還在跑的重算容器:
docker ps --filter "ancestor=investment-agent-agent-api"
本文件所有臨時容器一律 --rm,正常結束後不會殘留;若這條指令還列出
東西,代表還有批次在跑,不可以在這個狀態下恢復 scheduler——恢復後
scheduler 的排程任務會跟手動還在跑的容器打架,且沒有互斥機制。
3. 恢復 scheduler:
docker restart cortex-scheduler
- 真實環境驗證:下一個 scheduler 排程窗口跑過後,用 §3.3 的完成確認
SQL 之一(例如步驟 5 的
forward_return_label計數)確認新產生的每日 增量列使用的也是 adj 基準(可用當天新寫入的列與前一天已存在的(本次 重算過的)列做同款指標公式的連續性檢查,不應該在切換日出現斷點)。 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,順序不可顛倒。