Resources
Upgrading and reproducing results
On this page8
This guide covers the cumulative impact of 2.0.19 → 2.2.5: find which changes touch your strategy, then compare identical input data, signals, positions, trades and reports before and after the upgrade, before switching production. Pinning FinLab alone does not pin pandas behavior or later data corrections, so the second half of the page shows how to save the environment, data and settings too.
How to upgrade
Find what affects you
Go through the tables in the next section and check each trigger against your strategy. You can also filter the release feed in code to list every item in your range that may change results.
Compare before and after on identical data
Fix the input data and end_date, run once in each environment, and compare data, signals, positions, trades and return curves in that order, before summary metrics such as Sharpe.
Save the environment, data and settings
Record package versions, the input data actually used and every sim() parameter, so the same result can be reproduced later. See Make results reproducible.
Which results change
Financial statements and quarterly data
| Effective version | Condition and impact | Migration |
|---|---|---|
| From 2.0.21, retained in 2.2.16 | Quarterly bool/boolean masks preserve unknown values after date conversion; masks already changed to object by operations such as shift() are outside this fix (PR #160) | See the dtype requirements and fill order; convert object masks with .astype('boolean') before date conversion |
| 2.0.22, retained in 2.1.0 | Quarterly reindex() aligns on each company's filing date instead of returning all NaN; stock Series broadcast across stock columns (PR #166, PR #168) | Make quarter-to-date conversion explicit; use mul(s, axis=0/1) when a Series is ambiguous |
| 2.0.23, retained in 2.1.0 | When several quarters are filed the same day, or an old quarter is filed late, the newest disclosed quarter with a value is used (PR #169) | Reload inputs and recompute signals; there is no switch to keep the old values |
| 2.1.0 | Quarterly cross-sections convert to filing dates before rank, cs, sector and quarterly neutralize(), so only disclosed peers are compared (PR #184, PR #188) | Recompute rankings; a daily result's quarterly .loc no longer means the same quarter's accounts |
| 2.2.0 | Quarterly values missing a filing date or deadline no longer leak backward (PR #208) | Recompute; missing dates stay unknown, so don't invent availability dates |
| 2.2.0 | Monthly revenue deadlines and factor dates that fall on a make-up Saturday session keep that day instead of moving to Monday (PR #210) | Recompute affected signals, IC and bucket returns |
| 2.2.1 | Quarterly groupby_category() computes peer statistics by filing date (PR #220) | Use industry_mean.loc[:'2025-05-15'].tail(1); .loc['2025-Q1'] no longer means Q1 accounts |
Backtests and positions
| Effective version | Condition and impact | Migration |
|---|---|---|
| 2.1.0 | position[timing] keeps the False dates and holds nothing on them; before, the dropped dates made the backtest carry the previous holdings (PR #191) | Check what the timing filter is for; write position & timing for flat periods |
| 2.1.0 | NaN positions count as not held; resample=None no longer misses entries and exits around NaN (PR #194) | Combine sleeves with add(fill_value=0); fill new dates with fill_value=False and rebuild trades |
| 2.1.0 | Delisted holdings, or holdings with at least 20 trailing missing price bars, exit and are not bought back; inferring from missing prices does not confirm a delisting (PR #183) | Check exit dates and trades; historical performance can change |
| 2.1.0 | Boolean positions, or positions whose total absolute weight exceeds 1, are normalized before drawdown_control()/target_volatility() (PR #180) | Recompute exposure; sim() still caps at full investment, and these methods cannot add leverage |
| 2.1.0 | Future position rows with resample=None no longer extend the historical backtest (PR #196) | Compare backtest start and end dates; pass end_date for a fixed end |
| 2.1.0 | Naive timestamps use market time; on Taiwan non-session days before 15:00, signals are no longer moved back to the previous day, so the latest holdings can change (PR #185) | Save the execution instant and host timezone; fix end_date for historical comparisons and check the latest targets separately |
| 2.1.0 | set_universe() without filters resets the universe and keeps identifiers outside the market catalog; a filter that matches nothing raises (PR #192, PR #181) | Use set_universe(exclude_sector=[]) for a catalog-only scope; check filter spelling |
| 2.1.0 → 2.2.1 | In 2.1.0–2.2.0 sparse exit signals are one-off events; from 2.2.1 the default exit_mode='state' restores the 2.0.19 persistent condition (PR #177, Issue #224) | Use state for monthly or quarterly conditions; write event explicitly for one-off events and keep the original exit dates. Other fixes can still change results |
Factor analysis, indicators and weights
| Effective version | Condition and impact | Migration |
|---|---|---|
| 2.0.22, retained in 2.1.0 | calc_ic(rank=True) becomes a true Spearman IC; create_factor_data() quantiles become 1–5 with more even buckets (PR #166) | Recompute IC and bucket analysis; replace old 0–4 labels |
| 2.2.1 | Industry calculations, industry caps, custom reports and duplicate-column removal no longer depend on Python hash order, so they are deterministic across processes (Issue #225) | Save new comparison outputs; small decimal differences near a threshold can still change selections |
| 2.2.2 | Unknown TA-Lib parameter names raise; before, they could be ignored and the default used (Issue #274) | Write timeperiod, not timperiod; for adjusted prices use adjust_price=True or adj=True |
| 2.2.5 | On pandas 3, the first return after a price gap is restored; position.weight.inverse_volatility() and related weights, event_study() and leveraged Position.from_weight() can change (PR #331) | Recompute weights and event returns on pandas 3; pandas 2 results are unchanged |
Live order targets
These two change only the targets you order; backtest P&L and trades are unchanged.
| Effective version | Condition and impact | Migration |
|---|---|---|
| 2.2.3 | After a stop loss, next_weights, Position.from_report() and the backtest's rebalance allocation agree; live targets can grow, or drop stocks the rebalance excluded (PR #319, PR #314) | Inspect targets before ordering; both bool and float positions can change |
| 2.2.5 | After a mid-rebalance stop in a strategy holding shorts, report.weights/Position.from_report() no longer enlarge the remaining shorts; live short sizes shrink to match the backtest (PR #338) | Inspect targets before ordering; long-only strategies are unaffected |
Exact reproduction of old bugs requires the old wheel, identical dependencies and identical inputs; most fixes have no compatibility switch.
Quarterly Boolean masks: dtype and unknown values
The quarterly Boolean fix starts in 2.0.21 and remains in 2.2.16. A bool mask produced by comparing quarterly numbers, or a native nullable boolean mask, uses nullable boolean after index_str_to_date(): undisclosed values remain pd.NA, including after inversion with ~. Combining it with an ordinary daily bool mask using & produces ordinary bool, treating unknown values as False.
Inversion raises TypeError
The fix does not automatically convert object masks back to Boolean. Applying shift(1) first can introduce NaN and change the mask to object; date conversion keeps object, and ~ can still raise TypeError: bad operand type for unary ~: 'float'. Check mask.dtypes; for a mask holding only Boolean and missing values, convert to nullable boolean before date conversion:
mask = (q > 6).shift(1) # q contains quarterly numbers; this mask is object
dated = mask.astype('boolean').index_str_to_date()
inverted = ~dated # Unknown values remain pd.NAYou need plain bool or np.where
For a mask containing pd.NA, direct astype(bool) raises ValueError and direct np.where raises TypeError. Fill only if the strategy intends to treat unknown values as False, and note that the order of filling and inverting changes which stocks are selected:
import numpy as np
m = (q > 6).index_str_to_date()
selected = (~m).fillna(False) # Invert first: undisclosed values stay unselected
opposite = ~m.fillna(False) # Fill first: undisclosed values become True
plain_bool = selected.astype(bool)
weights = np.where(selected, 1, 0)Download the cases and run them outside source checkouts: python upgrade_results.py quarter_bool_object reproduces the object inversion error; python upgrade_results.py quarter_bool_dtype demonstrates conversion and fill order. With the released PyPI finlab 2.2.16 wheel, pandas 3.0.6 and numpy 2.5.4, the restored dtype is boolean and inversion on August 2 gives [True, False]. Undisclosed B on May 1 remains pd.NA after inversion; inverting then filling gives False, while filling then inverting gives True. The original quarter_bool case still gives [True, False]; the error does not affect every 2.2.16 mask.
Defaults, output and exceptions
| Change | What to update |
|---|---|
From 2.1.0, sim(upload=None) in Studio AI and the desktop app no longer uploads; cloud-scheduled runs and notebooks run manually on the Studio website still upload | Pass upload=True when you want an upload; compare research runs with upload=False |
From 2.0.22, report.trades['stock_id'] is a bare identifier | Use name for names and symbol for identifier plus name; the old column no longer includes names |
sim() supports end_date; without it, the backtest period still follows the signal rows | Use sim(position, end_date='2025-12-31', upload=False) for a fixed comparison window |
get_stats() adds camelCase aliases and keeps the old keys; get_stats(resample=...) never changed the frequency and now warns | Don't rely on resample to change statistics frequency; win_ratio and get_metrics()['winRate'] cover different trades |
From 2.1.0, factor ic()/calc_metric() no longer require the index name date | Still use an explicit date/stock MultiIndex; all-NaN factors remain NaN |
data.get() raises DatasetNotFoundError (a RuntimeError subclass) for unknown keys and DataError for download failures | Old RuntimeError handlers still catch unknown keys; in Pyodide catch DataError, with the HTTP status in error.status |
| From 2.2.1, quarterly date conversion no longer renames the source columns | If stack().reset_index() needs symbol, set df.columns.name='symbol' first |
| 2.2.1 fixes stale-cache errors with pyarrow < 16 | Upgrade FinLab and restart the kernel, without deleting caches; if you can't upgrade yet, use pyarrow>=16 |
From 2.2.2, PortfolioSyncManager.update() stops the entire update when strategies that are not rebalancing take up the total capital | Verify total_balance and the cash for purchases; save or sync only after success |
2.2.4 adds an optional data.indicator(strict=True) | TA-Lib parameter typos are checked by default; strict mode also refuses price selectors and does not support pandas_ta yet |
| The cache correction check added in 2.2.3 is reverted in 2.2.4; from 2.2.5, corrected data arrives within about 5 minutes without an extra request on every call | From 2.2.5 just call again; older versions should upgrade. Frames already returned remain snapshots |
To keep cash after a stop, specify float weights before sim(): five stocks at 0.2 each is not the same as a Boolean basket, which redistributes equally. Exclude stocks with missing prices before computing the weights, and check position_limit.
Compare two versions with minimal cases
Download the cases. Each case calls only the affected calculation, with fixed prices, financial statements and filing dates. Quarters, the session calendar and the clock are synthetic fixtures, so the cases don't depend on later-corrected online data; calculations use the installed APIs and sim() uses the actual engine. Backtest cases require a normal FinLab login; see the Quickstart. These synthetic outputs locate differences between versions and do not represent real strategy performance.
Put the downloaded file outside source checkouts, create two environments and run the same case in each:
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_icReplace rank_ic with any case below to compare the two versions.
The runs used macOS, CPython 3.13.3, pandas 2.2.3, numpy 2.2.6 and released PyPI FinLab wheels; TA-Lib 0.8.1 for the indicator case, a UTC host with a fixed clock for the date cases, and PYTHONHASHSEED=0 for duplicate columns. Decimals in the table are rounded; the full program prints the raw values. NaN means unknown or missing, and old exceptions are actual outputs.
Output of the 27 cases on 2.0.19 and 2.2.4
| Case | 2.0.19 | 2.2.4 | What it compares |
|---|---|---|---|
quarter_bool | TypeError: bad operand type for unary ~: 'float' | [True, False] | Inverting a quarterly mask expanded to filing dates |
latest_quarter | [5.0, 9.0] | [7.0, 3.0] | The value used when two quarters are filed the same day |
quarter_reindex | [NaN, NaN] | [5.0, NaN] | One company has filed, another hasn't |
quarter_rank | 0.5 | 1.0 | Cross-sectional percentile when the first company files |
quarter_group | ['2023-Q1', 7.0] | ['2023-05-01 00:00:00', 5.0] | Index and first value of the industry mean |
missing_date | 5.0 | NaN | A statement missing its date no longer fills early |
series | [NaN, NaN] | [200.0, 300.0] | Broadcast direction of a stock-weight Series |
timing | [True, True] | [True, False, True] | Flat dates retained |
rank_ic | 0.7850264209630101 | 1.0 | Spearman IC |
quantiles | [0,1,1,2,2,2,3,4,4,4] | [1,1,2,2,3,3,4,4,5,5] | Buckets for ten stocks |
drawdown | [2.0, 0.6536433173] | [1.0, 0.4806677708] | Gross exposure in rows 0 and 5 |
volatility | [2.0, 0.0609895989] | [1.0, 0.0609895989] | The warmup row is normalized; not every row differs |
nan_position | ['2022-01-04'] | ['2022-01-04','2022-01-07'] | Actual entry dates around NaN |
short_position | IndexError | '2022-04-22' | End date of a two-row position |
future_position | '2022-04-22' | '2022-01-17' | Future signals no longer extend a sparse position's backtest |
delisted | NaN | 0.0 | Position B after long trailing missing prices |
monthly_saturday | '2016-09-12' | '2016-09-10' | Trading-day alignment used for monthly revenue |
factor_saturday | '2016-09-12' | '2016-09-10' | Make-up session included in factor data |
duplicate_columns | ['c','a','b'] | ['a','b','c'] | Column order with the same seed |
universe_reset | ['2330','2317'] | ['2330','2317','0050O'] | Whether a filter-free reset still drops catalog-external identifiers |
universe_empty | ['2330','2317','0050O'] | ValueError | A misspelled sector no longer silently uses the whole market |
from_weight | AssertionError: Unexpectedly insufficient funds. | 2330 and 2317: 5 lots each, weight 0.5 each | Weights summing to 1.6 are scaled to full investment first |
clock | {'2330': 1.0} | {'2317': 1.0} | Latest signal at Taiwan Saturday 00:30 on a UTC host |
indicator_typo | NaN (still the default 30-day SMA) | ValueError, lists timeperiod | The timperiod=3 typo is no longer ignored |
stop_target | 2317 and 1301: 0.2 each | 2317 and 1301: 0.5 each | Next rebalance target after three stops; backtest unchanged |
stop_reentry | 2330, 2317 and 2454: 1/3 each | 2317 and 2454: 0.5 each | 2330, excluded at this rebalance, is not bought back; backtest unchanged |
pandas_left | DataFrame, 2×2 | DataFrame, 2×2 | Pandas-left results agree at the endpoint versions |
Results on intermediate versions
Intermediate versions also matter for the upgrade path. The same cases with identical pandas and numpy:
| Case | Before the fix | Intermediate | After the fix |
|---|---|---|---|
hold_default | 2.0.19: [True,False,False] | 2.2.0: [True,False,True] | 2.2.1/2.2.4: [True,False,False] |
pandas_left | 2.0.21: pandas DataFrame, 2×2 | 2.0.23 (yanked): FinlabDataFrame, 1×0 | 2.1.0/2.2.4: pandas DataFrame, 2×2 |
Install yanked versions only to investigate historical behavior, in isolated environments. hold_until(exit_mode='event') selects event mode explicitly from 2.2.1; older versions don't accept exit_mode.
Make results reproducible
Reproducing a backtest takes the same code, the same package versions, the same input data and the same sim() parameters; save all four.
Pin the environment
Keep production and comparison environments separate. Save the current environment first:
python -VV > python-version.txt
python -m pip freeze > requirements.lock.txt
python -m pip download --only-binary=:all: -r requirements.lock.txt -d wheelsRebuild on the same Python version, operating system and CPU architecture:
python -m pip install --no-index --find-links=wheels -r requirements.lock.txtDon't copy the version numbers of a verification environment onto an existing production one; pin the full dependency set production actually uses: FinLab, pandas, numpy, pyarrow, scipy, indicator packages (TA-Lib, pandas_ta) and model packages. For ML strategies, also save the models, random seeds and feature column order. Restart the Python or Jupyter kernel after upgrading, so old packages aren't still loaded in memory.
pandas versions change results too: in pandas 2.2, pct_change() still fills missing values forward by default, while pandas 3 uses fill_method=None. Choose the missing-value handling explicitly; see pandas 2.2 and 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()) # Keep missing values explicitly
print(s.ffill().pct_change(fill_method=None).tolist()) # Fill first, explicitlyFrom 2.2.5, FinLab's built-in position.weight methods such as inverse_volatility(), event_study() and Position.from_weight() carry prices forward before computing returns, so pandas 2 and 3 agree; your own pct_change() calls still need an explicit choice.
Automatically save every strategy input
From 2.2.22, data.snapshot() saves the tables that data.get() and data.gets() actually return during one complete run to a new local directory, including financial statements and the prices, benchmarks and classifications read internally by sim(). Date ranges and explicit data versions are supported. Wrap the entire strategy from its first data read, and do the backtest and any report calculations inside the block:
from finlab import data
from finlab.backtest import sim
data.set_market('us')
def research():
close = data.get('price:adj_close')
position = (close > close.average(20)).loc['2020-01-01':]
report = sim(
position,
resample='M',
market='US_STOCK',
end_date='2025-12-31',
upload=False,
)
return report, report.get_stats()
# The directory must not already exist; the complete manifest is written only after the block exits successfully.
with data.snapshot('research-baseline'):
baseline, baseline_stats = research()
# Keep the same code, parameters, date range, Python and package versions.
with data.snapshot('research-baseline', mode='replay'):
restored, restored_stats = research()
# Normal reads resume outside the block. Save a new run to compare newer data.
with data.snapshot('research-new-data'):
latest, latest_stats = research()Worker threads in the same Python process take part in recording and replay, so wait for all parallel work to finish before leaving the block. Only one recording or replay block may be active at a time.
Replay reads only the saved inputs. Missing tables, unreadable files, or different read ranges or stock universes raise an error instead of falling back to the normal local cache or the latest cloud data. lazy=True is supported; the actual tables are saved before computation.
manifest.json records the dataset-to-file mapping and the Python, finlab, pandas, numpy and pyarrow versions; replay requires these versions to match. A snapshot preserves the inputs actually used in that run, not an atomic version of all tables at one instant. It cannot recover expired data that was never backed up, and it doesn't add new dates. Recording needs additional local disk space. Keep the entire directory, and load only trusted pickle files that you saved yourself.
These are not saved automatically:
- The strategy code, the full environment lock, random seeds, strategy parameters and a fixed
end_date - Variables populated before the block, custom prices or external data supplied by a custom Market, third-party APIs and other custom files: move the reads into the block or back them up separately
Historical replay does not mean a strategy is currently tradable. sim() still performs its normal login and authorization checks; set upload=False to avoid uploading replay results.
When recording fails
Recording fails if repeated reads with the same arguments return different values during the run; rerun the entire strategy in a new directory. Failed or incomplete runs produce no complete manifest, even if the strategy catches a data-read error. Their directories cannot be replayed and are never overwritten automatically.
Record data versions by hand
From 2.2.0, paid members can list the generations still retained for a dataset with data.versions(), with publication and replacement times, size and hash, then download a chosen generation with data.get(dataset, version=...). Pick the generation first, then read it; don't fetch the latest data and attach metadata from a later request to it.
import json
from pathlib import Path
from finlab import data
data.set_market('us')
datasets = [
'price:adj_close',
'us_income_statement:net_income',
] # every input of the strategy
manifest = {}
for dataset in datasets:
versions = data.versions(dataset)
if versions.empty:
raise RuntimeError(f'{dataset} has no available version')
row = versions.iloc[-1]
generation = str(row['generation'])
snapshot = data.get(dataset, version=generation)
# Save the actual input; keep the file-to-dataset mapping in the 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, indent=2))Each generation pins one table, not an atomic snapshot across tables. Adjusted prices, filing dates, the trading calendar, universe membership and industry classifications are inputs too; save them as well. Keep saved data within your usage rights, and load only trusted pickles you saved yourself.
Retention and as_of limits
Retention is whatever data.versions() lists at the time, not a guaranteed three weeks. The 2.2.4 interface describes deletion once a version has been replaced for 30 days and has 10 newer versions, effective 2026-09-26; it cannot recreate versions that were never retained. An expired generation cannot be downloaded again from its id or hash, so save the inputs themselves for long-term reproduction. The hash verifies the server's data object, not a backtest result.
A naive as_of uses Asia/Taipei time for every market, and a bare date means midnight, not that day's close. version and as_of are mutually exclusive and cannot be combined with start/end, intraday tick or minute data, or use_local_data_only. Permanent financial statement vintages are in financial_statement_versions. The API is experimental.
Save strategy and backtest settings
Save the strategy source, the execution instant and timezone, the environment lock, the data manifest, the processed input prices and the positions. Write down every sim() parameter instead of relying on defaults: market, trade price, rebalance frequency and offset, fees, taxes, stop loss and take profit, trailing stop, touched-exit mode, position limit, retain_cost_when_rebalance, stop_trading_next_period, start and end dates, and upload. For a custom Market, price matrix or benchmark, save its code and inputs, not just its name.
import json
from pathlib import Path
from finlab import data
from finlab.backtest import sim
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
position = (close / close.shift(126)).is_largest(
20
) # the position your strategy produces
settings = dict(
resample='M',
trade_at_price='close',
market='US_STOCK',
fee_ratio=0,
tax_ratio=0,
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') # Save the position you actually passed
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 or cloud reports save the results, but do not archive all source data, dependencies, source code or custom markets, and a backtest report is not a record of actual executed trades. To verify a reproduction, compare data values and dates, signals, positions, trade dates and quantities and the return curve, then summary metrics such as Sharpe. Fix end_date first, and validate the latest rebalance and live targets separately.
Track release impact in code
Public feeds: JSON and Atom. Both are generated when the docs are built, from the same changelog and reviewed, item-by-item impact annotations, and cover published releases from 2.0.19. Unreleased is excluded.
Each item has three Boolean flags (results_may_change, behavior_change, experimental), functions, trigger, references and the full description. results_may_change covers calculation, selection and backtest numbers; changes to live targets only use behavior_change, so read the description either way. False does not mean the data will never be corrected.
This lists every item after 2.0.19 and up to 2.2.5 flagged as possibly changing results or behavior:
import requests
version = lambda text: tuple(map(int, text.split('.')))
start, end = version('2.0.19'), version('2.2.5')
response = requests.get('https://finlab.finance/docs/releases.json', timeout=30)
response.raise_for_status()
for release in response.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'])The range excludes the start version and includes the target; compare versions as numbers, not strings. The two yanked releases remain in the feed for readers who upgraded through them. Annotations record each release's change as it was, so watch for later reversions, such as hold_until in 2.2.1 and the cache check in 2.2.4. Docs and feeds are deployed and verified before the release is uploaded to PyPI.
Next steps
- Changelog Every change in every release.
- Backtesting Every
sim()parameter and fill timing. - Download data Using
data.get()and finding dataset names.
Was this page helpful?