文件
開啟 Studio

資源

版本升級與結果重現

本頁內容8

這頁整理 2.0.19 → 2.2.5 的累積影響:先找出哪些變更碰得到你的策略,再用同一份資料比較升級前後的訊號、部位、交易與報表,確認後才切換正式環境。單純鎖住 finlab 版本,無法固定 pandas 的計算方式或之後更正的資料,所以後半頁說明怎麼把環境、資料與設定都保存下來。

升級的做法

找出受影響的地方

看下一節的表,逐條確認觸發條件是否符合你的策略。也可以用程式篩選版本 feed,列出區間內所有可能改變結果的項目。

用同一份資料比較升級前後

固定輸入資料與 end_date,在兩個環境各跑一次,依序比較資料、訊號、position、交易、報酬曲線,最後才看 Sharpe 等摘要數字。

保存環境、資料與設定

把套件版本、實際用到的輸入資料、sim() 參數都存下來,之後才能重現同一個結果。作法見讓結果可以重現。

哪些策略結果會改變

財報與季度資料

生效版本觸發條件與影響升級時怎麼處理
2.0.21 起,2.2.16 仍保留bool/boolean 季度遮罩轉日期後保留未知值;先 shift() 等操作變成 object 的遮罩不適用(PR #160)見季報布林遮罩的 dtype 前提與填值順序;object 遮罩先 .astype('boolean') 再轉日期
2.0.22,保留於 2.1.0季度 reindex() 改按各公司公布日對齊,不再全為 NaN;股票 Series 改按股票欄位廣播(PR #166、PR #168)明確指定季度轉日期;Series 意義不明時用 mul(s, axis=0/1)
2.0.23,保留於 2.1.0同日公布多季、或舊季晚交,改用已公布且有值的最新季度(PR #169)重新取得輸入、計算訊號;沒有保留錯誤值的開關
2.1.0季度橫斷面先轉公布日,再做排名、cs、sector 與季度 neutralize();避免比較尚未公開的同季財報(PR #184、PR #188)重新排名;日頻結果不能再用季度 .loc 當作同季財報
2.2.0缺少公布日或截止日的季報數值不再提前填入(PR #208)重新計算;缺日期維持未知,不自行補出可交易日
2.2.0月營收截止日與因子日期遇補班交易週六,保留當天,不再延至週一(PR #210)重算包含這些日期的訊號、IC 與分組報酬
2.2.1季度 groupby_category() 改按公布日計算同業統計(PR #220)改成 industry_mean.loc[:'2025-05-15'].tail(1);.loc['2025-Q1'] 不再表示第一季財報

回測與部位

生效版本觸發條件與影響升級時怎麼處理
2.1.0position[timing] 保留 False 日期並空手,過去刪除日期後回測會沿用前期持股(PR #191)確認擇時目的;要空手可明寫 position & timing
2.1.0NaN 部位視為未持有;resample=None 不再漏掉 NaN 前後的進出場(PR #194)合併不同策略用 add(fill_value=0);新建日期補 fill_value=False,重算交易
2.1.0下市或價格尾端至少 20 個交易日缺價的持股出場,之後不再買回;缺價推定不等於確認下市(PR #183)檢查出場日期與交易;歷史績效可能改變
2.1.0布林或總絕對權重大於 1 的部位,先正規化再做 drawdown_control()/target_volatility()(PR #180)重算曝險;回測仍限制最高滿倉,不支援用這些方法加槓桿
2.1.0resample=None 的未來部位列不再延長歷史回測期間(PR #196)比較回測起訖日;需要固定結束日請傳 end_date
2.1.0無時區的部位改採市場時間;非交易日 15:00 前不再把台股訊號退回前一天,最新持股可能改變(PR #185)保存執行時刻、主機時區;歷史比較固定 end_date,最新一期另查換股狀態
2.1.0set_universe() 不帶條件取消篩選,保留市場目錄外的代號;篩選結果為空改報錯(PR #192、PR #181)想限制在市場目錄用 set_universe(exclude_sector=[]);檢查條件拼字
2.1.0 → 2.2.12.1.0~2.2.0 的稀疏出場訊號是單次事件;2.2.1 起預設 exit_mode='state',恢復 2.0.19 的持續條件(PR #177、Issue #224)月/季條件用 state;單次事件明寫 event,保留出場原日期。其他修正仍可能改結果

因子分析、指標與權重

生效版本觸發條件與影響升級時怎麼處理
2.0.22,保留於 2.1.0calc_ic(rank=True) 改為真正的 Spearman;create_factor_data() 的組別改為 1~5、分組較均勻(PR #166)重算 IC 與分組分析;不要沿用舊 0~4 組別標籤
2.2.1產業計算、產業權重、custom report、重複欄位去重不再受 Python hash 順序影響(Issue #225)重存比對結果;門檻附近的小數尾數仍可能影響選股
2.2.2TA-Lib 不支援的參數名稱改報錯,過去可能被忽略而用預設值計算(Issue #274)把 timperiod 改為 timeperiod;還原價用 adjust_price=True 或 adj=True
2.2.5pandas 3 下,價格中間有缺值的股票補回缺值後第一天的報酬;position.weight.inverse_volatility() 等權重方法、event_study() 與槓桿 Position.from_weight() 的結果可能改變(PR #331)使用 pandas 3 的策略重算權重與事件報酬;pandas 2 結果不變

實單下單目標

這兩項只改變下單的目標部位,回測損益與交易紀錄不變。

生效版本觸發條件與影響升級時怎麼處理
2.2.3停損後的 next_weights、Position.from_report() 與回測的換股分配一致;實單目標可能變大、或移除已排除股票(PR #319、PR #314)下單前確認目標;bool 與 float 都可能受影響
2.2.5持有空單的策略在換股之間有持股停損後,report.weights、Position.from_report() 不再放大其餘空單;實單放空部位會變小,與回測持有的部位一致(PR #338)下單前確認目標。只做多的策略不受影響

如果要完全重現舊套件的錯誤計算,需要舊 wheel、同一組相依版本與同一份資料。修正項目通常沒有「沿用舊錯誤」的選項。

季報布林遮罩:dtype 與未知值

2.0.21 是季度布林遮罩修正開始生效的版本,2.2.16 仍保留此修正。季頻數值直接比較產生的 bool 遮罩,或原生 nullable boolean 遮罩,經 index_str_to_date() 轉為公布日後使用 nullable boolean:尚未公布的值保持 pd.NA,~ 取反也保持未知。與一般日頻 bool 遮罩以 & 合併時,結果為一般 bool,未知視為 False。

取反時出現 TypeError

這項修正不會自動把 object 遮罩轉回布林。若先 shift(1),首列 NaN 可能讓遮罩變成 object;轉日期後仍是 object,~ 仍可能拋出 TypeError: bad operand type for unary ~: 'float'。先確認 mask.dtypes;對只有布林值與缺值的遮罩,明確轉成 nullable boolean 後再轉日期:

mask = (q > 6).shift(1)  # q 是季頻數值表;此路徑的 mask 為 object
dated = mask.astype('boolean').index_str_to_date()
inverted = ~dated  # 未知值仍是 pd.NA

需要一般 bool 或 np.where 時

含 pd.NA 的遮罩直接 astype(bool) 會報 ValueError,直接 np.where 會報 TypeError。只有策略決定「未知視為 False」時才先填值,而且填值與取反的順序會改變選股語意:

import numpy as np

m = (q > 6).index_str_to_date()
selected = (~m).fillna(False)  # 先取反再填值:尚未公布仍不選
opposite = ~m.fillna(False)  # 先填值再取反:尚未公布會變 True
plain_bool = selected.astype(bool)
weights = np.where(selected, 1, 0)

下載完整案例,在原始碼目錄外執行 python upgrade_results.py quarter_bool_object 可重現 object 的取反錯誤;python upgrade_results.py quarter_bool_dtype 示範轉型與填值順序。以 PyPI finlab 2.2.16、pandas 3.0.6、numpy 2.5.4 實跑,轉型後 dtype 為 boolean,8/2 取反為 [True, False];5/1 尚未公布的 B 取反仍為 pd.NA,先取反再填值為 False,先填值再取反為 True。原本的 quarter_bool 案例仍為 [True, False],不表示所有 2.2.16 遮罩都會報錯。

預設值、輸出與例外

變更需要更新的寫法
Studio AI 與桌面版的 sim(upload=None) 自 2.1.0 不再自動上傳;雲端排程與在 Studio 網站手動執行的筆記本仍會上傳需要上傳時傳 upload=True;研究比較用 upload=False
report.trades['stock_id'] 自 2.0.22 改為純代號名稱用 name,代號加名稱用 symbol;舊欄位不再包含名稱
sim() 支援 end_date;未指定時仍會依訊號列距決定回測期間固定比較窗口用 sim(position, end_date='2025-12-31', upload=False)
get_stats() 增加 camelCase 別名,舊欄位保留;resample 原本無效(從未改變統計頻率),現在警告不依賴 resample 改統計頻率;win_ratio 與 get_metrics()['winRate'] 的交易口徑不同
因子 ic()/calc_metric() 自 2.1.0 不要求索引名稱 date仍建議使用明確的日期/股票 MultiIndex;全 NaN 因子保留 NaN
data.get() 找不到 key 改為 DatasetNotFoundError(RuntimeError 的子類別);下載失敗改為 DataErrorRuntimeError 舊捕捉仍支援找不到 key;Pyodide 用 except DataError,HTTP 狀態用 error.status
2.2.1 季報轉日期不再改寫原表欄位名稱若 stack().reset_index() 需要 symbol,先設 df.columns.name='symbol'
2.2.1 修正 pyarrow < 16 的過期快取錯誤升級後重啟 kernel;暫時不能升級可用 pyarrow>=16,不用刪快取
2.2.2 的 PortfolioSyncManager.update() 遇總資金被不換股策略占滿會整次停止確認 total_balance 與購股現金;成功後才儲存/同步
2.2.4 的 data.indicator(strict=True) 是選用功能預設仍檢查 TA-Lib 參數錯字;嚴格模式拒絕價格 selector,pandas_ta 尚不支援
2.2.3 增加快取修正檢查,2.2.4 撤回;2.2.5 起資料修正後最慢約 5 分鐘取得,不再每次呼叫都多一次查詢2.2.5 起重新呼叫即可;更舊版本請升級。已回傳的表格仍是當時快照

若停損後希望保留現金,請在回測前明確使用浮點權重;五檔各 0.2 不等同布林部位自動等權重重分配。先排除缺少價格的股票,再計算權重,並確認 position_limit。

用最小案例比對兩個版本

下載完整案例。每個案例只呼叫受影響的計算,使用固定價格、財報與公布日期。季度、交易日曆與時鐘以 fixture 提供,不依賴日後更正的線上資料;sim() 使用真正的回測引擎。執行回測案例需要正常 FinLab 登入,見登入說明。這些合成輸出用來定位版本差異,不能代表真實策略績效。

先將下載的檔案放到原始碼目錄以外,再建立兩個環境,用同一個案例名稱各跑一次:

python3.13 -m venv before
python3.13 -m venv after
before/bin/python -m pip install "finlab==2.0.19" "pandas==2.2.3" "numpy==2.2.6" "TA-Lib==0.8.1" matplotlib
after/bin/python -m pip install "finlab==2.2.4" "pandas==2.2.3" "numpy==2.2.6" "TA-Lib==0.8.1" matplotlib
before/bin/python upgrade_results.py rank_ic
after/bin/python upgrade_results.py rank_ic

把最後的 rank_ic 換成下表的案例名稱,即可用同一段程式比較兩版。

以下實跑環境為 macOS、CPython 3.13.3、pandas 2.2.3、numpy 2.2.6、PyPI 已發布的 FinLab wheel;TA-Lib 案例用 TA-Lib 0.8.1。日期案例採 UTC 主機、固定時鐘。重複欄位案例指定 PYTHONHASHSEED=0。小數在表內四捨五入,完整程式會印出原值。NaN 表示未知或缺值,舊版錯誤也是實跑結果。

27 個案例在 2.0.19 與 2.2.4 的輸出
案例2.0.19 輸出2.2.4 輸出比較的是什麼
quarter_boolTypeError: bad operand type for unary ~: 'float'[True, False]公布日展開後的遮罩反相
latest_quarter[5.0, 9.0][7.0, 3.0]同日公布兩季所採用的值
quarter_reindex[NaN, NaN][5.0, NaN]一家公司已公布、另一家尚未公布
quarter_rank0.51.0第一家公司公布時的橫斷面百分位
quarter_group['2023-Q1', 7.0]['2023-05-01 00:00:00', 5.0]產業平均的索引與第一筆值
missing_date5.0NaN缺少日期的財報不再提前填入
series[NaN, NaN][200.0, 300.0]股票權重 Series 的廣播方向
timing[True, True][True, False, True]保留空手日期
rank_ic0.78502642096301011.0Spearman IC
quantiles[0,1,1,2,2,2,3,4,4,4][1,1,2,2,3,3,4,4,5,5]十檔股票的分位組別
drawdown[2.0, 0.6536433173][1.0, 0.4806677708]第 0、5 列的總絕對權重
volatility[2.0, 0.0609895989][1.0, 0.0609895989]暖機列先正規化,並非每一列都不同
nan_position['2022-01-04']['2022-01-04','2022-01-07']NaN 前後實際進場的日期
short_positionIndexError'2022-04-22'兩列部位的回測結束日
future_position'2022-04-22''2022-01-17'未來訊號不再延長稀疏部位的回測
delistedNaN0.0價格尾端長期缺價後的 B 部位
monthly_saturday'2016-09-12''2016-09-10'月營收使用的交易日對齊路徑
factor_saturday'2016-09-12''2016-09-10'因子資料納入的補班交易日
duplicate_columns['c','a','b']['a','b','c']同一 seed 的欄位排列
universe_reset['2330','2317']['2330','2317','0050O']不帶條件是否仍排除目錄外代號
universe_empty['2330','2317','0050O']ValueError拼錯產業不再默默使用全市場
from_weightAssertionError: Unexpectedly insufficient funds.2330、2317 各 5 張、權重各 0.5權重總和 1.6 先縮為滿倉
clock{'2330': 1.0}{'2317': 1.0}台北週六 00:30 的最新訊號,主機為 UTC
indicator_typoNaN(仍用預設 30 日均線)ValueError,列出 timeperiodtimperiod=3 的錯字不再被忽略
stop_target2317、1301 各 0.22317、1301 各 0.5停損三檔後的下一次換股目標;回測計算不變
stop_reentry2330、2317、2454 各 1/32317、2454 各 0.5不再買回這次換股已排除的 2330;回測計算不變
pandas_leftDataFrame、2×2DataFrame、2×2端點版本的 pandas 左側運算結果相同
中間版本的結果

中間版本也會影響升級路徑。同一案例以相同 pandas/numpy 實跑:

案例修正前中間版本修正後
hold_default2.0.19:[True,False,False]2.2.0:[True,False,True]2.2.1/2.2.4:[True,False,False]
pandas_left2.0.21:DataFrame、2×22.0.23:FinlabDataFrame、1×02.1.0/2.2.4:DataFrame、2×2

2.0.23 是已撤回版本,只有追查歷史行為時才在隔離環境安裝。hold_until(exit_mode='event') 可在 2.2.1 起明確選擇事件模式;舊版不接受 exit_mode。

讓結果可以重現

要重現一次回測,需要同一份程式、同一組套件版本、同一份輸入資料與同一組 sim() 參數,四樣都要另外保存。

鎖住執行環境

正式策略與新版本的比較環境分開安裝。先保存現有環境:

python -VV > python-version.txt
python -m pip freeze > requirements.lock.txt
python -m pip download --only-binary=:all: -r requirements.lock.txt -d wheels

重建時使用相同的 Python 版本、作業系統與 CPU 架構:

python -m pip install --no-index --find-links=wheels -r requirements.lock.txt

不要把驗證環境的版本號直接套到既有正式環境;先鎖住正式策略實際使用的 finlab、pandas、numpy、pyarrow、scipy、TA-Lib/pandas_ta 等完整相依版本。ML 策略另保存模型、亂數 seed、特徵欄位順序與模型套件版本。升級後重啟 Python/Jupyter kernel,避免記憶體裡仍載入舊套件。

pandas 的版本也會改變結果:pandas 2.2 的 pct_change() 預設仍會先往前填缺值,pandas 3 改為 fill_method=None。策略應明確選擇缺值處理,參見 pandas 2.2 文件與 pandas 3 文件。

import pandas as pd

s = pd.Series([100.0, None, 110.0])
print(s.pct_change().tolist())
# pandas 2.2.3:[nan, 0.0, 0.10000000000000009]
# pandas 3.0.6:[nan, nan, nan]
print(s.pct_change(fill_method=None).tolist())  # 明確保留缺值
print(s.ffill().pct_change(fill_method=None).tolist())  # 明確先填值

2.2.5 起,FinLab 內建的 position.weight.inverse_volatility() 等權重方法、event_study() 與 Position.from_weight() 會先沿用缺值前的價格再計算報酬,pandas 2 與 3 結果相同;策略裡自己呼叫的 pct_change() 仍需依上例明確選擇。

自動保存整支策略的資料輸入

2.2.22 起,data.snapshot() 可將完整一次執行中,data.get()、data.gets() 實際回傳的表格保存到新的本機目錄,包含財報與 sim() 內部讀取的價格、基準、分類等資料,也支援帶日期範圍或指定版本的讀取。請從第一次取資料開始包住整支策略,並在區塊內完成回測及需要的報告計算:

from finlab import data
from finlab.backtest import sim

def research():
    close = data.get('price:收盤價')
    position = (close > close.average(20)).loc['2020-01-01':]
    report = sim(position, resample='M', end_date='2025-12-31', upload=False)
    stats = report.get_stats()
    return report, stats

# 目錄必須尚未存在;成功離開區塊後才寫入完整清單。
with data.snapshot('research-baseline'):
    baseline, baseline_stats = research()

# 保持相同程式、參數、日期區間、Python 與套件版本。
with data.snapshot('research-baseline', mode='replay'):
    restored, restored_stats = research()

# 離開區塊就恢復一般讀取,可另存新資料結果作比較。
with data.snapshot('research-new-data'):
    latest, latest_stats = research()

同一個 Python 程式的工作執行緒也會參與保存/還原;請等所有平行工作完成後才離開區塊。同時只允許一個保存/還原區塊。

還原只讀保存的輸入:缺表、檔案讀不到、讀取範圍/股票母體不同時直接報錯,不會混入本機一般快取或雲端最新值。lazy=True 仍可使用,保存的是運算前的實際資料表。

manifest.json 記錄資料與檔名對照,以及 Python、finlab、pandas、numpy、pyarrow 版本;還原要求這些版本相同。保存的是本次真正用到的輸入,並非所有表在同一時刻的資料版本;不會找回已過期且未備份的舊資料,也不會自動增加新日期。需要額外本機空間,請保留整個目錄;pickle 僅讀取自己保存、可信任的檔案。

這些不會自動保存,需要另外處理:

  • 程式碼、完整環境 lock、亂數種子、策略參數及固定的 end_date
  • 區塊外已讀入的變數、自訂價格/Market 外部資料、第三方 API 與其他自訂檔案:請將資料讀取移入區塊或另外備份

歷史還原不代表當下可交易;sim() 的登入授權仍照常檢查,結果上傳請設 upload=False。

保存失敗的情況

相同條件重複取同一份資料時,若值在保存途中變動,這次保存會失敗,請改用新目錄重新完整執行。即使策略自行捕捉讀表錯誤,未保存完整輸入的執行也不會產生完整清單。未完成或失敗的目錄不能還原,也不會被自動覆寫。

手動記錄資料版本

2.2.0 起,付費資料會員可用 data.versions() 列出仍保留的 generation、公布/取代時間、大小與 hash,再下載指定 generation。先選 generation,再讀取;不要先抓最新值、再用另一個請求的最新 metadata 當作它的版本。

import json
from pathlib import Path
from finlab import data

datasets = ['price:收盤價', 'etl:adj_close']  # 列出策略的所有輸入
manifest = {}
for dataset in datasets:
    versions = data.versions(dataset)
    if versions.empty:
        raise RuntimeError(f'{dataset} 沒有可用版本')
    row = versions.iloc[-1]
    generation = str(row['generation'])
    snapshot = data.get(dataset, version=generation)
    # 保存實際輸入;檔名與資料集名稱對照也留在 manifest
    filename = f'input-{len(manifest)}.pkl'
    snapshot.to_pickle(filename)
    manifest[dataset] = {
        'generation': generation,
        'hash': str(row['hash']),
        'time_created': str(row['time_created']),
        'file': filename,
    }
Path('data-versions.json').write_text(
    json.dumps(manifest, ensure_ascii=False, indent=2)
)

generation 固定單張表,不代表跨資料表的原子快照。還原價、公布日期、交易日曆、股票母體與產業分類也是輸入,請一併保存。自行保存資料限於你的使用權限;pickle 只讀取自己保存、可信任的檔案。

版本保留期與 as_of 的限制

保留期以 data.versions() 當次列出的內容為準,不能把「約三週」當成長期保證。2.2.4 的版本介面說明為:版本被取代滿 30 天且已有 10 個較新版本後可刪除;這項規則自 2026-09-26 生效,不會補出先前未保留的版本。指定 generation 過期後,僅靠 generation/hash 無法重新下載,因此長期重現需自行保存實際輸入。hash 是伺服器資料物件的驗證值,不是回測結果的 hash。

as_of 未帶時區時採台北時間,只有日期代表當天 00:00,不代表當日收盤。version 與 as_of 只能擇一,不能搭配 start/end、逐筆/分鐘資料或 use_local_data_only。財報的永久歷史版本改查 financial_statement_versions。這個 API 仍屬實驗性。

保存策略與回測設定

保存策略原始碼、實際執行時刻與時區、環境 lock、資料 manifest、經處理的輸入價格及 position,並把所有 sim() 參數明寫,不依賴預設值:市場、成交價、換股頻率/偏移、費率、稅率、停損停利、移動停利、觸價模式、持股上限、retain_cost_when_rebalance、stop_trading_next_period、開始/結束範圍與 upload。自訂 Market、價格矩陣與 benchmark 也需保存程式及輸入,不能只保存名稱。

import json
from pathlib import Path
from finlab.backtest import sim

settings = dict(
    resample='M',
    trade_at_price='close',
    market='TW_STOCK',
    fee_ratio=0.001425,
    tax_ratio=0.003,
    position_limit=1,
    stop_loss=None,
    take_profit=None,
    trail_stop=None,
    touched_exit=False,
    retain_cost_when_rebalance=False,
    stop_trading_next_period=True,
    end_date='2025-12-31',
    upload=False,
)
position.to_pickle('position.pkl')  # 保存你實際傳入的 position
Path('sim-settings.json').write_text(json.dumps(settings, indent=2))
report = sim(position, **settings)
report.to_pickle('report.pkl')
report.to_html('report.html')

report.pkl、HTML 或雲端報告可保存結果,但不會自動封存全部來源資料、相依環境、原始碼與自訂市場。回測報告也不是實際成交紀錄。驗證重現時,依序比較資料值與日期、訊號、position、交易日期與數量、報酬曲線,再比較 Sharpe 等摘要;先固定 end_date,再另外驗證最新一期的換股與實單目標。

用程式追蹤版本影響

公開 feed:JSON、Atom。兩者在文件建置時,從同一份更新日誌與逐項審閱的影響分類產生,涵蓋 2.0.19 起的已發布版本;Unreleased 不會當作已發布。

每個項目包含三個布林標記(results_may_change、behavior_change、experimental)、functions、trigger、references 與完整說明。results_may_change 指計算/選股/回測數字;只改實單目標則用 behavior_change,仍應閱讀說明。false 不代表資料從此不會更正。

下面的程式列出 2.0.19 之後、到 2.2.5 為止所有標記為可能改變結果或行為的項目:

import requests

version = lambda text: tuple(map(int, text.split('.')))
start, end = version('2.0.19'), version('2.2.5')
feed = requests.get('https://finlab.finance/docs/releases.json', timeout=30)
feed.raise_for_status()
for release in feed.json()['releases']:
    if start < version(release['version']) <= end:
        for item in release['items']:
            if item['results_may_change'] or item['behavior_change']:
                print(release['version'], item['functions'], item['trigger'])

區間排除起始版、包含目標版;不要用字串比較版本。兩個 yanked 版本仍列在 feed,避免漏掉曾升級至該版的歷史。分類保留每版當時的變更,所以需注意後續的還原:例如 2.2.1 的 hold_until 與 2.2.4 的快取檢查。文件與 feed 會先上線並驗證,再上傳新版至 PyPI。

下一步

↑↓ 選擇 Enter 開啟 Esc 關閉