Docs
Open Studio

API reference

FinlabDataFrame

On this page13

FinlabDataFrame extends the pandas DataFrame for writing stock strategies. Most datasets returned by data.get() are this type, so daily prices and quarterly fundamentals can be combined directly, without manual reindex work. It inherits pandas, so every pandas method still works; what it adds is automatic alignment and the stock-selection methods on this page.

If you are new to data.get(), start with finlab.data.

Which function to use

You want toUse
Combine daily, monthly and quarterly dataPlain + - * / > < & | operators: they align automatically
Compute an n-period moving averageaverage()
Check whether a value is higher or lower than n periods agorise() / fall()
Require a condition to hold for n periodssustain()
Pick the n largest or smallest stocks each dayis_largest() / is_smallest()
Rank across stocks each dayrank(axis=1, pct=True) or df.cs.rank()
Rank within each industryindustry_rank() or df.sector(...).rank()
Hold until an exit condition, stop-loss or take-profithold_until()
Exit when a condition is metexit_when()
Get the price of each entry (Taiwan market only)entry_price()
Remove industry effects (industry neutralization)neutralize_industry()
Compute statistics per industrydf.sector(...).mean() and friends, or groupby_category() (Taiwan market only)
Winsorize, standardize or bucket a factordf.cs
Build portfolio weightsdf.weight
Keep only the last row of each month, week or quarterrebalance()

Combine data of different frequencies

Daily prices and quarterly fundamentals can go into the same condition:

from finlab import data

data.set_market('us')

close = data.get('price:adj_close')  # Daily
revenue = data.get(
    'us_income_statement:revenue'
)  # Quarterly, aligned to each SEC filing date

# Automatic date alignment (revenue is forward-filled to daily frequency)
position = (close > close.average(20)) & (revenue > 1e9)

With +, -, *, /, >, >=, ==, <, <=, &, |, ~, data of different frequencies does not need to be aligned first. Before the operation, the dates (index) of both sides are unioned and forward-filled, and the stocks (columns) are intersected:

  • Low frequency to high frequency: forward fill.
  • Quarterly reports (financial statements): each company's value takes effect on its filing date (for U.S. stocks, the SEC filing date).

So each date only uses data that had been published by that date:

from finlab import data

data.set_market('us')

close = data.get('price:adj_close')  # Updated daily
print(close.index[0])  # 2016-01-04

revenue = data.get('us_income_statement:revenue')  # One row per fiscal quarter
print(revenue.index[0])  # 2015-US-Q1

# position is daily data, revenue has been auto-filled
position = (close > 100) & (revenue > 1e9)

Compared with a plain DataFrame, a FinlabDataFrame differs in only two ways:

  1. It has extra methods such as is_largest() and sustain().
  2. Arithmetic and comparison operators union the indexes and intersect the columns of both operands first.

The left operand decides the alignment: a plain pandas DataFrame on the left gives a different result (see the common questions at the end).

Operations with a pd.Series. The direction follows the Series index: a date (or quarter/month string) index aligns on dates; a stock-symbol index aligns on stocks and applies to every date (missing stocks count as False for & / | and as NaN for arithmetic). If the index matches both or neither, an error is raised; use df.mul(s, axis=0) (dates) or df.mul(s, axis=1) (stocks) to choose explicitly. Market timing with a date-indexed boolean Series: position[cond] and position & cond keep every date and hold cash on False dates.

Moving averages and price moves

average()

n-period moving average. The result is still a FinlabDataFrame, so you can keep chaining methods.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

ma20 = close.average(20)  # 20-day moving average
ma60 = close.average(60)  # 60-day moving average

# Moving average crossover strategy
golden_cross = ma20 > ma60
ParameterTypeDefaultDescription
npositive intrequiredWindow length

average(20) is equivalent to rolling(20, min_periods=10).mean(): a stock gets NaN when fewer than n / 2 values in the window are available.

rise() / fall()

rise(n) is True where the value is higher than the value n rows earlier (self > self.shift(n)); fall(n) is True where it is lower. Combine with sustain() for consecutive moves.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

rising = close.rise(3)  # Higher than 3 days ago
falling = close.fall(2)  # Lower than 2 days ago

# Rising on each of the last 3 days
rising_3_days = close.rise().sustain(3)
ParameterTypeDefaultDescription
npositive int1Compare with the value n periods earlier

sustain()

Checks whether a condition holds for n periods.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
ma20 = close.average(20)

# Price stays above the 20-day MA for 5 consecutive days
position = (close > ma20).sustain(5)
ParameterTypeDefaultDescription
nwindowintrequiredNumber of periods to look back
nsatisfyint or NoneNoneMinimum number of True periods in the window; None requires every period

rise(n) asks whether the value is higher than n periods ago; sustain(n) asks whether the condition is True for n consecutive periods, and sustain(n, k) whether it was True at least k times in the last n periods.

Pick stocks by rank

is_largest() / is_smallest()

Returns a boolean DataFrame that is True for the n largest (or smallest) values of each row, i.e. the top stocks on each date.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
marketcap = data.get('etl:us_market_value')

# The 30 smallest by market cap (small caps)
small_cap = marketcap.is_smallest(30)

# The 20 largest by 20-day momentum
momentum = (close / close.shift(20)).is_largest(20)

# Momentum stocks among small caps
position = small_cap & momentum
ParameterTypeDefaultDescription
npositive intrequiredNumber of stocks selected per row
  • Re-ranks every period (daily/monthly).
  • If fewer than n stocks are available, all are selected.
  • Quarterly rows are converted to filing dates first, so each date only compares values already public on that date.

To rank only the stocks that pass a condition, exclude the others with score[cond] (or score.where(cond)):

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
score = close / close.shift(20)  # 20-day momentum
cond = close > close.average(60)  # above the 60-day average

# Stocks failing cond are NaN and not ranked; if fewer than 10 pass, only those are selected
position = score[cond].is_largest(10)

(cond * score).is_largest(10) gives stocks failing cond a score of 0. When fewer than 10 stocks pass, those 0-score stocks fill the slots in column order (for example A, AA, AAL, ...). When a row fills its slots with stocks tied at its lowest score (highest for is_smallest), finlab emits a TopNPaddingWarning; the selection is unchanged.

rank()

Cross-sectional or time-series ranking with the same arguments as pd.DataFrame.rank (axis, pct, ascending, method, na_option). Use axis=1 to rank across stocks on each date; for within-industry ranking, use industry_rank().

from finlab import data

data.set_market('us')
marketcap = data.get('etl:us_market_value')

# Percentile ranking (0-1)
cap_rank = marketcap.rank(pct=True, axis=1)

# Top 30% by market cap
position = cap_rank > 0.7

# Or use is_largest (more intuitive)
position = marketcap.is_largest(int(len(marketcap.columns) * 0.3))
ParameterTypeDefaultDescription
validboolean DataFrame or SeriesNoneOnly cells where valid is True take part in the ranking. Other cells are set to NaN before ranking, so they do not affect the pct=True denominator. Typical use: after fillna, pass the original notna() mask so newly listed stocks or stocks with short history do not distort the percentiles
*args, **kwargsPassed to pd.DataFrame.rank

Ranking along the time axis (axis=0, the pandas default) can leak future data and emits a LookaheadWarning; use rolling().rank(), expanding().rank() or axis=1 instead.

industry_rank()

Ranks within each industry: 0 is the lowest in the industry, 1 the highest.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

# Within-industry ranking (0-1)
industry_rank = close.industry_rank()

# Stocks with an industry percentile score above 0.7
position = industry_rank > 0.7
ParameterTypeDefaultDescription
categorieslist[str] or NoneNoneIndustries to rank within. None uses all industries (for Taiwan stocks, only top-level categories)
  • For U.S. stocks, industries come from the industry column of data.get('us_company_profile') (USMarket.get_industry()). categories=[...] restricts the ranking to the listed industries.
  • For Taiwan stocks (tw_stock and rotc_stock), classifications come from the TPEx industry value chain via data.get('security_industry_themes').
  • Ranked per date with rank(axis=1, pct=True) within each category; stocks in multiple categories get the mean percentile.
  • Quarterly data is converted to filing dates first, so each date is compared with the values the industry had published by then.

Set entries and exits

hold_until()

Buys when the entry signal is True and holds until an exit condition, stop-loss, take-profit or trailing stop triggers.

from finlab import data
from finlab.backtest import sim

data.set_market('us')
close = data.get('price:adj_close')
position = close > close.average(20)
exit_condition = close < close.average(60)

# Basic usage: exit condition
position_with_exit = position.hold_until(exit_condition)

# Exit condition + 10% stop-loss + 20% take-profit
position_with_stops = position.hold_until(
    exit=exit_condition,
    stop_loss=0.1,
    take_profit=0.2,
)

# Exit condition + 10% trailing stop, activated only after +20% profit
position_with_trail = position.hold_until(
    exit=exit_condition,
    trail_stop=0.1,
    trail_stop_activation=0.2,
)

report = sim(position_with_stops, resample='M')
report.display()
  • hold_until() extends the holding period until an exit triggers.
  • Stop-loss and take-profit are calculated from the entry price.
  • trail_stop exits when the return falls from the holding-period peak by the configured amount; trail_stop_activation can require a minimum profit before the trailing stop activates. See the common questions for how the trailing stop is computed.
ParameterTypeDefaultDescription
exitDataFramerequiredExit signal. A quarterly string index (such as 2024-US-Q1) is converted to filing dates first
nstocks_limitint or NoneNoneMaximum number of stocks held at once; None means no limit
stop_lossfloat or DataFrame-np.infExit when the price falls this far below cost, e.g. 0.1. A dates x stocks DataFrame gives each stock its own threshold per day
take_profitfloat or DataFramenp.infExit when the price rises this far above cost; same rules as stop_loss
trail_stopfloatnp.infTrailing stop size; np.inf disables it
trail_stop_activationfloat or None0Profit required before the trailing stop activates; None is the same as 0
trade_atstr'close'Reference price for the stops: 'close' or 'open'
rankDataFrame or NoneNoneWhen entries exceed nstocks_limit, stocks with larger values enter first
exit_modestr'state'Keyword only: 'state' or 'event', see below; any other value raises ValueError

Exit signals as a state or as events (exit_mode)

Entry signals act only on their own dates. When the exit has sparser dates than the entries (financial statements, weekly/monthly conditions), exit_mode decides what the dates missing from exit mean:

exit_modeDates missing from exitSuited to
'state' (default)Keep the previous row's value until the next rowLow-frequency conditions such as quarterly financial statements: once published, the exit holds and the stock is not re-entered
'event'No exitData that lists event dates only: exit on the dates exit has, then buy again whenever the entry is True
from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
entries = close > close.average(20)
revenue = data.get('us_income_statement:revenue')
yoy = revenue / revenue.shift(4) - 1  # quarterly revenue, year over year

# Exit when revenue YoY is negative; re-enter only after a later filing is no longer negative
position = entries.hold_until(yoy < 0)

# Exit only on the filing dates
position = entries.hold_until(yoy < 0, exit_mode='event')

The default was 'event' in 2.1.0–2.2.0 and 'state' before 2.1.0 (through 2.0.23) and from now on. To keep 2.1.0–2.2.0 positions, add exit_mode='event'.

When both modes give the same result

Both modes give the same result when exit has a row on every entries date, for example when both come from the same daily price table; quarterly string indexes are compared after conversion to dates. Extra dates added by rank or by the prices for stop-loss/take-profit carry no entry signals and don't matter. A daily exit that misses an entries date, for example because it updates one day later or skips a trading day, can differ: on that date 'state' keeps the previous row and 'event' treats it as no exit.

'event' only sees the dates of the exit it receives

In these cases the exit passed to hold_until has already been forward-filled onto a denser timeline. Each filled row counts as an event of its own, so 'event' cannot recover exits that originally appeared only on release dates:

  • A condition combined with daily data, for example (yoy < 0) | (close < close.average(60)): the result equals 'state'. To treat it as events, pass the original low-frequency condition.
  • A quarterly string index (such as 2024-US-Q1): index_str_to_date() indexes by every company's disclosure dates and carries each company's value onto the other companies' disclosure dates, so while that value is True, 'event' also exits on other companies' disclosure dates, not only on the company's own.
  • A lazy exit (from data.get(..., lazy=True) or .lazy()): the lazy engine forward-fills it onto the lazy timeline before calling hold_until, so exit_mode='event' silently gives the same result as 'state'. For event semantics, pass an eager exit, or call .collect() on the low-frequency exit condition alone first:
# Eager exit condition (data fetched without lazy=True)
position = entries.lazy().hold_until(yoy < 0, exit_mode='event').collect()

# A lazy exit condition: collect it on its own first
revenue_lazy = data.get('us_income_statement:revenue', lazy=True)
exits = (revenue_lazy / revenue_lazy.shift(4) < 1).collect()
position = entries.lazy().hold_until(exits, exit_mode='event').collect()

exit_when()

Exits when a condition is met: the position opens when the entry signal turns from False to True, and closes when the exit signal is True or the entry signal turns back to False.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
ma20 = close.average(20)
ma60 = close.average(60)

entry = ma20 > ma60  # Entry: short MA crosses above long MA
exit_signal = ma20 < ma60  # Exit: short MA crosses below long MA

position = entry.exit_when(exit_signal)
ParameterTypeDefaultDescription
exitDataFramerequiredExit signal

Use exit_when() for custom exit conditions (e.g., technical indicators) and hold_until() for fixed stop-loss/take-profit percentages.

entry_price()

Returns the price of each entry. Taiwan market only: it reads Taiwan adjusted prices.

from finlab import data

data.set_market('tw')
close = data.get('price:close')
position = close.is_largest(10)

cost = position.entry_price('close')
ParameterTypeDefaultDescription
trade_atstr'close''close' uses the Taiwan adjusted close (etl:adj_close); any other value uses the adjusted open (etl:adj_open)

The entry price is the adjusted price on the trading day after the entry signal; if the stock is halted that day, the first later day with a price.

Neutralize and group by industry

neutralize_industry()

Industry neutralization: regresses the factor on industry dummy variables across stocks and returns the residuals, removing industry effects.

from finlab import data

data.set_market('us')
raw_close = data.get('price:close')  # unadjusted price for valuation

# Raw factor (with industry influence): trailing-12-month earnings yield
eps = data.get('us_income_statement:eps_diluted')
earnings_yield = eps.rolling(4).sum() / raw_close

# U.S. sector of each stock
profile = data.get('us_company_profile')
sectors = profile[['symbol', 'sector']].rename(columns={'sector': 'category'})

# Industry neutralization (regression on sector dummies)
neutral_factor = earnings_yield.neutralize_industry(categories=sectors)

# Use the neutralized factor
position = neutral_factor.is_largest(30)
ParameterTypeDefaultDescription
categoriesDataFrame or NoneNoneA table with symbol (or stock_id) and category columns. None uses the Taiwan industry classification (security_categories); for U.S. stocks pass one, for example sectors from us_company_profile
add_constboolTrueAdd a constant term to the regression; one industry dummy is then dropped automatically to avoid multicollinearity

Why neutralize: some industries naturally trade at higher or lower valuations (e.g., utilities vs. technology); neutralization avoids over-concentration in a single industry and improves strategy stability.

groupby_category()

Groups stocks by industry, like pd.DataFrame.groupby(). Taiwan market only: it uses the Taiwan industry categories.

from finlab import data

data.set_market('tw')
close = data.get('price:close')
industry_mean = close.groupby_category().mean()

Quarterly data is converted to filing dates before grouping, so each date's industry statistics include only values already public that day. For U.S. stocks, use df.sector(mapping) instead.

neutralize_industry_batch()

Neutralizes several factors at once. It accepts a list or mapping of factors and returns the same container shape; factors with identical dates and symbols share the category mapping, date conversion and vectorized industry passes. Pair it with factor_alignment_profile() to measure how long alignment takes.

from finlab import data
from finlab.dataframe import factor_alignment_profile, neutralize_industry_batch

data.set_market('us')
raw_close = data.get('price:close')
eps = data.get('us_income_statement:eps_diluted')
net_income = data.get('us_income_statement:net_income')
equity = data.get('us_balance_sheet:total_stockholders_equity')
profile = data.get('us_company_profile')
sectors = profile[['symbol', 'sector']].rename(columns={'sector': 'category'})

value = eps.rolling(4).sum() / raw_close  # daily earnings yield
quality = net_income / equity  # quarterly ROE

neutral = neutralize_industry_batch(
    {"value": value, "quality": quality}, categories=sectors
)
with factor_alignment_profile() as alignment:
    combined = value + quality  # daily + quarterly factors
print(alignment.to_dict())
ParameterTypeDefaultDescription
factorslist or dictrequiredFactors to neutralize
categoriesDataFrame or NoneNoneSame as in neutralize_industry()
add_constboolTrueSame as in neutralize_industry()

Default categories use a cache tied to the active market and data lifecycle. Changing storage, forcing a categories refresh, and data.clear() invalidate it. You can also call data.clear_category_cache() explicitly and inspect raw and mapping hits with data.category_cache_stats().

Cross-sectional transforms: df.cs

The df.cs (cross-sectional) accessor transforms values across all stocks on each date, typically to preprocess a factor.

from finlab import data

data.set_market('us')
net_income = data.get('us_income_statement:net_income')
equity = data.get('us_balance_sheet:total_stockholders_equity')
roe = net_income / equity[equity > 0]  # quarterly ROE, positive equity only

# Factor preprocessing: winsorize + standardize
clean_roe = roe.cs.winsorize(0.05, 0.95).cs.zscore()

# Bucket into 5 groups for backtesting
group = roe.cs.bucket(5)
long_group = group == 5  # Long the top quintile

# Percentile rank stock selection
position = roe.cs.rank() > 0.8  # Top 20%
MethodDescriptionExample
rank()Percentile rank, in (0, 1]factor.cs.rank()
winsorize(lower, upper)Percentile winsorizationfactor.cs.winsorize(0.05, 0.95)
bucket(n)Equal-quantile bucketing (1..n)factor.cs.bucket(5)
zscore()Standardization (mean 0, std 1)factor.cs.zscore()
demean()Subtract the cross-sectional meanfactor.cs.demean()

df.cs.rank() works like pandas' own df.rank(pct=True, axis=1), with a clearer name.

Sector-relative transforms and aggregation: df.sector

The df.sector accessor offers two kinds of functionality:

  1. Within-sector transforms: the same 5 methods as df.cs (rank / winsorize / bucket / zscore / demean), computed within each sector.
  2. Within-sector aggregation: mean / std / median / sum / min / max / count, the sector-level statistics.

Call it with a mapping, df.sector(by), to choose the classification: a dict or pd.Series from stock symbol to sector name, or a dates x symbols DataFrame for time-varying classifications. Stocks not in the mapping are left out (NaN). Without a mapping it uses the Taiwan industry classification (security_categories).

from finlab import data

data.set_market('us')
net_income = data.get('us_income_statement:net_income')
equity = data.get('us_balance_sheet:total_stockholders_equity')
roe = net_income / equity[equity > 0]  # quarterly ROE, positive equity only

# Symbol -> sector, e.g. {'AAPL': 'Technology', 'JPM': 'Financial Services', ...}
profile = data.get('us_company_profile')
sectors = dict(zip(profile['symbol'], profile['sector']))

# Within-sector rank (top 20% ROE within each sector)
position = roe.sector(sectors).rank() > 0.8

# Within-sector standardization (removes sector bias)
neutralized_roe = roe.sector(sectors).zscore()

# Stocks above their sector's average ROE
sector_avg_roe = roe.sector(sectors).mean()
above_avg = roe > sector_avg_roe
MethodDescriptionExample
rank()Within-sector percentile rankfactor.sector(sectors).rank()
winsorize(lower, upper)Within-sector winsorizationfactor.sector(sectors).winsorize(0.05, 0.95)
bucket(n)Within-sector bucketingfactor.sector(sectors).bucket(3)
zscore()Within-sector standardizationfactor.sector(sectors).zscore()
demean()Within-sector demeaningfactor.sector(sectors).demean()
mean()Sector meanroe.sector(sectors).mean()
std(ddof=1)Sector standard deviationroe.sector(sectors).std()
median()Sector medianroe.sector(sectors).median()
sum()Sector sumrevenue.sector(sectors).sum()
min()Sector minimumclose.sector(sectors).min()
max()Sector maximumclose.sector(sectors).max()
count()Count of non-NaN values in the sectorroe.sector(sectors).count()

df.sector.zscore() achieves the same effect as neutralize_industry() (industry neutralization); df.sector is more flexible, adding ranking, bucketing and aggregation.

Build portfolio weights: df.weight

The df.weight accessor builds and adjusts weights directly on a stock selection (boolean or numeric DataFrame). All methods return a FinlabDataFrame, so they chain.

from finlab import data
from finlab.backtest import sim

data.set_market('us')
close = data.get('price:adj_close')
volume = data.get('price:volume')
marketcap = data.get('etl:us_market_value')

# Stock selection
position = marketcap.is_smallest(30)

# Chain weight construction
weighted = (
    position.weight.inverse_volatility(
        window=60, price=close
    )  # Inverse volatility weighting
    .weight.clip_by_volume(
        total_fund=1e7, volume=close * volume
    )  # At most 10% of daily dollar volume
    .rebalance('ME')  # Keep the last day of each month (rebalance dates)
    .weight.limit_turnover(max_turnover=0.3)  # Turnover cap at 30% per rebalance
    .weight.drawdown_control(max_drawdown=0.15, price=close)  # Drawdown control at 15%
)

report = sim(weighted, resample='M')
report.display()
MethodDescriptionExample
cap_industry(max_weight)Cap industry weight, redistribute excess proportionally (Taiwan market only: Taiwan industry categories)w.weight.cap_industry(0.3)
clip_by_volume(total_fund, max_participation_ratio, volume, window)Clip stock weight by average daily dollar volumew.weight.clip_by_volume(1e7, volume=close * volume)
inverse_volatility(window, price)Inverse volatility weighting: low-vol stocks get higher weightw.weight.inverse_volatility(60)
risk_parity(window, price)Risk parity weighting: equal risk contribution per holdingw.weight.risk_parity(60)
correlation(window, price, diversify)Adjust weights by correlation: diversify or concentratew.weight.correlation(diversify=True)
target_volatility(target, window, price)Scale weights to achieve a target annualized volatilityw.weight.target_volatility(0.15)
limit_turnover(max_turnover)Cap two-way turnover between adjacent rows (call .rebalance() on daily positions first)w.rebalance('ME').weight.limit_turnover(0.3)
drawdown_control(max_drawdown, price)Reduce exposure when drawdown exceeds a thresholdw.weight.drawdown_control(0.15)

The price argument of the volatility, correlation and drawdown methods and the volume argument of clip_by_volume default to Taiwan data (etl:adj_close, Taiwan trading value). For U.S. stocks pass them explicitly.

Putting it together: a multi-filter strategy

from finlab import data
from finlab.backtest import sim

# Step 1: Load data
data.set_market('us')
close = data.get('price:adj_close')
marketcap = data.get('etl:us_market_value')
revenue = data.get('us_income_statement:revenue')  # quarterly
net_income = data.get('us_income_statement:net_income')
equity = data.get('us_balance_sheet:total_stockholders_equity')
roe = net_income / equity[equity > 0]  # quarterly ROE, positive equity only

# Step 2: Define filter conditions
cond1 = marketcap.is_smallest(100)  # Small-cap
cond2 = (revenue / revenue.shift(4)) > 1.2  # Revenue up 20% year over year
cond3 = roe.rank(pct=True, axis=1) > 0.7  # Top 30% ROE
cond4 = (close > close.average(20)).sustain(3)  # Above 20MA for 3 days

# Step 3: Combine conditions
position = cond1 & cond2 & cond3 & cond4

# Step 4: Limit number of holdings
position = position.is_largest(20)  # Max 20 stocks

# Step 5: Backtest with stop-loss/take-profit
report = sim(position, resample='M', stop_loss=0.1, take_profit=0.2)
report.display()

# Step 6: Analyze performance
stats = report.get_stats()
print(f"Annual return: {stats['cagr']:.2%}")
print(f"Sharpe ratio: {stats['daily_sharpe']:.2f}")
print(f"Max drawdown: {stats['max_drawdown']:.2%}")

The FinlabDataFrame class

FinlabDataFrame()

Strategy syntactic sugar on top of pd.DataFrame. Everything returned by finlab.data.get() is a FinlabDataFrame; you can also wrap any DataFrame yourself.

import pandas as pd
from finlab.dataframe import FinlabDataFrame

df = FinlabDataFrame(pd.DataFrame({'AAPL': [1.0, 2.0], 'MSFT': [3.0, 4.0]}))

Besides the methods above, these are defined on FinlabDataFrame itself. Quarterly data (index such as 2024-US-Q3) is converted to filing dates (index_str_to_date()) before cross-sectional operations, so each date only uses values that were public on that date.

MethodSignatureDescription
averageaverage(n: int) -> FinlabDataFramen-period moving average, see average()
is_largestis_largest(n: int) -> FinlabDataFrameTrue for the n largest values of each row, see is_largest()
is_smallestis_smallest(n: int) -> FinlabDataFrameTrue for the n smallest values of each row
rankrank(*args, valid=None, **kwargs) -> FinlabDataFramepandas rank that warns on time-axis ranking, see rank()
rise / fallrise(n: int = 1), fall(n: int = 1)value higher / lower than n periods ago, see rise() / fall()
quantile_rowquantile_row(c: float) -> pd.Seriesthe c quantile of each row (across stocks)
rebalancerebalance(freq) -> FinlabDataFramekeep only the last observation per period
lazylazy() -> LazyWidewrap into a lazy computation handle
reindexreindex(*args, **kwargs) -> FinlabDataFramepd.DataFrame.reindex that understands quarterly string indexes
alignalign(other, *args, **kwargs)pd.DataFrame.align that converts a quarterly side to dates first
where / maskwhere(cond, *args, **kwargs), mask(cond, *args, **kwargs)pd.DataFrame.where / mask that convert a quarterly frame to dates first
corrwithcorrwith(other, *args, **kwargs) -> pd.Seriespairwise correlations with plain pandas alignment and arithmetic
fillnafillna(value=None, **kwargs)fills as pandas does, and keeps a boolean table boolean when value is a bool
rollingrolling(*args, **kwargs)pd.DataFrame.rolling; numeric frames compute their columns concurrently
pct_changepct_change(periods=1, fill_method=None, *args, **kwargs) -> FinlabDataFramefractional change without filling missing values by default, as in pandas 3 on every supported pandas version; use frame.ffill().pct_change() to carry the last valid price across a gap

quantile_row()

Returns the c quantile of each row, that is, of all stocks on each date (quarterly data is converted to filing dates first).

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
close.quantile_row(0.9)  # 90th percentile of stock prices on each date
ParameterTypeDefaultDescription
cfloatrequiredQuantile between 0 and 1, e.g. 0.9

rebalance()

Downsamples to the last observation of each period, equivalent to self.resample(freq).last(), except that a period labelled before its last row ('MS', 'QS') is dated at that row.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
monthly = (close > close.average(20)).rebalance('ME')  # keep each month's last row
ParameterTypeDefaultDescription
freqstr, pd.Index or listrequiredA pandas frequency string such as 'ME' (month-end), 'W' (weekly) or 'QE' (quarter-end), or explicit dates to select with reindex

lazy()

Wraps the DataFrame into a LazyWide proxy that records operations without executing them. Call .rebalance(freq) or .collect() on the result to execute, so the work is only done for the dates you need.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
position = (
    close.lazy().rolling(60).mean().rank(axis=1, pct=True).is_largest(10).rebalance('M')
)

reindex() / align() / where() / corrwith()

These behave like their pandas versions, with quarterly data handled for you.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
revenue = data.get('us_income_statement:revenue')

latest = revenue.reindex(close.index, method='ffill')  # each stock's latest disclosed value
  • reindex(*args, **kwargs): same as pd.DataFrame.reindex, except that reindexing a quarterly (2024-US-Q3) frame onto dates first converts the quarters to filing dates with index_str_to_date(). df.reindex(close.index, method='ffill') therefore gives each stock's latest disclosed value instead of all NaN or a TypeError.
  • align(other, *args, **kwargs): same as pd.DataFrame.align, converting a quarterly side to dates first. Returns a tuple (left, right).
  • where(cond, ...) / mask(cond, ...): with a date-indexed cond, roe.where(cond) works like roe.index_str_to_date().where(cond) instead of raising "putmask: mask and data must be the same size". FinlabDataFrame date conditions carry their last observation forward to dates missing from the condition; plain pandas conditions keep pandas' exact-date alignment.
  • corrwith(other, *args, **kwargs): same as pd.DataFrame.corrwith, computed with plain pandas alignment and arithmetic (no forward fill).

Common questions

Why does a plain pandas DataFrame on the left give a different result?

The left operand decides the alignment. With a plain pd.DataFrame on the left, + - * / // % ** & | ^ use pandas rules: no forward fill of dates, union of stocks, and a plain DataFrame result (comparisons > < == are not affected). When the dates or stocks differ, finlab emits a PandasLeftOperandWarning.

import pandas as pd
from finlab import data
from finlab.dataframe import FinlabDataFrame

data.set_market('us')
close = data.get('price:adj_close')
mask = pd.DataFrame(True, index=close.index[:1], columns=close.columns)

mask & (close > 0)  # pandas alignment: True on the first date only, warns
(close > 0) & FinlabDataFrame(mask)  # FinlabDataFrame alignment: forward-filled to every date

Put the FinlabDataFrame on the left, or wrap the other side with FinlabDataFrame(df).

How do I chain multiple FinlabDataFrame methods?
from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

# Method chaining (similar to pandas method chaining)
position = (
    close.average(20)  # Compute 20MA
    .rank(pct=True, axis=1)  # Rank
    .is_largest(30)  # Select top 30
)

# Equivalent to
ma20 = close.average(20)
ranked = ma20.rank(pct=True, axis=1)
position = ranked.is_largest(30)
What is the difference between is_largest() and rank() + threshold?

is_largest(30) always selects 30 stocks: a fixed number of holdings, suited to fixed capital allocation. rank(pct=True, axis=1) > 0.8 selects the top 20%: the count varies with the number of stocks, adapting to market conditions.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

position1 = close.is_largest(30)  # Fixed count
position2 = close.rank(pct=True, axis=1) > 0.8  # Fixed percentage
How does the trailing stop (trail_stop) work?

For a regular long position with trade_at_price="close" and touched_exit=False, the drawdown from the peak is measured against entry cost, not against the peak: an exit signal fires when (holding-period peak price - current price) / entry cost > trail_stop. The comparison is strict, subject to floating-point rounding at the boundary.

This is a signal threshold, not a guaranteed fill price: execution dates and prices still follow the sim() execution settings. touched_exit=True uses intraday highs/lows and touch rules instead. The default retain_cost_when_rebalance=False resets the entry cost and peak on every executed rebalance, even for symbols that stay held, and with resample=None a change in any symbol's position rebalances the whole portfolio; see Rebalance timing and stop basis.

Trailing stops can be handled by the backtest engine with sim(trail_stop=...), or converted into exit signals while building positions with hold_until(trail_stop=...). Use trail_stop_activation when the trailing stop should activate only after a configured profit threshold is reached.

import pandas as pd
from finlab import data
from finlab.backtest import sim

data.set_market('us')
close = data.get('price:adj_close')
position = close > close.average(20)

# Example: Entry price is $100
# - stop_loss=0.1: Exit if price drops below 90
# - take_profit=0.2: Exit when price reaches 120 (fixed take-profit)
# - trail_stop=0.2: Signal exit once price falls more than 20% of the entry price below the peak

# Scenario: Entry at 100 -> rises to 150 -> drops to 120
# - take_profit=0.2: Exits at 120, does not wait for 150
# - trail_stop=0.2: Threshold = 150 - 0.2 * 100 = 130 (not 120); signal fires once price falls below 130

# Fixed take-profit
report_fixed = sim(position, resample='M', stop_loss=0.1, take_profit=0.2)

# Trailing stop
report_trailing = sim(position, resample='M', stop_loss=0.1, trail_stop=0.2)

# Generate trailing-stop exits in hold_until first
position_trailing = position.hold_until(
    exit=pd.DataFrame(False, index=position.index, columns=position.columns),
    trail_stop=0.2,
    trail_stop_activation=0.3,
)
How do I use pandas methods on a FinlabDataFrame?

FinlabDataFrame is fully compatible with pandas: every pandas method works, and results remain FinlabDataFrame, keeping the extended features.

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')

close.rolling(20).mean()
close.fillna(0)
close.dropna()

Next steps

For pandas itself, see the pandas documentation.

↑↓ Move Enter Open Esc Close