Skip to content

FinlabDataFrame: Auto-Alignment and Stock Selection API

FinlabDataFrame extends Pandas DataFrame for stock strategy development. Most datasets returned by data.get() use this type, so daily prices and quarterly fundamentals can be combined directly without manual reindex work.

Task Main method Example
Align daily, monthly, and quarterly data Automatic alignment (close > close.average(20)) & (revenue > 1e9)
Select the top or bottom N stocks is_largest() / is_smallest() marketcap.is_smallest(30)
Build positions with stop-loss or take-profit exits hold_until() position.hold_until(stop_loss=0.1)
Run cross-sectional and sector transforms df.cs / df.sector factor.cs.rank()
Construct portfolio weights df.weight position.weight.equal()

If you are new to data.get(), start with finlab.data. For a full strategy workflow, continue with Research to Production.

Use Cases

  • Automatically align data of different frequencies (daily, weekly, monthly, quarterly)
  • Quickly perform stock selection (ranking, top-N filtering)
  • Compute moving averages and technical indicators
  • Manage entry/exit signals (hold until stop-loss/take-profit)
  • Industry neutralization
  • Cross-sectional factor preprocessing (df.cs: rank, winsorize, z-score)
  • Within-sector transforms and aggregation (df.sector: sector rank, mean, std)
  • Portfolio weight construction (df.weight: inverse volatility, risk parity, drawdown control)

Quick Examples

Auto-Alignment and Stock Selection

from finlab import data

data.set_market('us')

# data.get() returns FinlabDataFrame
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)

Ranking and Filtering

from finlab import data

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

# Select the 30 smallest by market cap
small_cap = marketcap.is_smallest(30)

# Select the 20 largest by price momentum
momentum = (close / close.shift(20)).is_largest(20)

# Combine strategies
position = small_cap & momentum

Position Management

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)

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

# Backtest
report = sim(position_with_stops, resample='M')
report.display()

FinlabDataFrame vs pandas DataFrame

Feature pandas DataFrame FinlabDataFrame
Basic operations Fully supported Fully supported (inherits pandas)
Auto-alignment Requires manual handling Automatically aligns different frequency data
Stock selection Write your own Built-in is_largest(), is_smallest()
Technical indicators Requires extra packages Built-in average(), rise(), fall()
Position management Write your own Built-in hold_until(), exit_when()
Industry analysis Write your own Built-in industry_rank(), neutralize_industry()
Factor preprocessing Write your own Built-in df.cs cross-sectional transforms
Within-sector analysis Write your own Built-in df.sector sector transforms & aggregation
Weight construction Write your own Built-in df.weight with 8 weighting methods

API Reference

FinlabDataFrame

class FinlabDataFrame(pd.DataFrame)

Strategy syntactic sugar on top of pd.DataFrame. Everything returned by finlab.data.get() is a FinlabDataFrame; it behaves like a normal DataFrame and adds the stock-selection methods documented on this page. You can also wrap any DataFrame: FinlabDataFrame(df) (from finlab.dataframe import FinlabDataFrame).

Automatic alignment. With +, -, *, /, >, >=, ==, <, <=, &, |, ~, data of different frequencies (daily prices, quarterly financial statements) 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:

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')  # daily
net_income = data.get('us_income_statement:net_income')  # quarterly
equity = data.get('us_balance_sheet:total_stockholders_equity')  # quarterly

cond1 = close > 50
cond2 = (net_income / equity) > 0  # positive ROE
cond_1_2 = cond1 & cond2  # aligned automatically

Compared with a plain DataFrame, a FinlabDataFrame only differs in 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.

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.

The methods below 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.

Method Signature Description
average average(n: int) -> FinlabDataFrame n-period moving average, see average()
is_largest is_largest(n: int) -> FinlabDataFrame True for the n largest values of each row, see is_largest()
is_smallest is_smallest(n: int) -> FinlabDataFrame True for the n smallest values of each row
rank rank(*args, valid=None, **kwargs) -> FinlabDataFrame pandas rank with look-ahead protection, see rank()
rise / fall rise(n: int = 1), fall(n: int = 1) value higher / lower than n periods ago, see rise() / fall()
quantile_row quantile_row(c: float) -> pd.Series the c quantile of each row (across stocks)
rebalance rebalance(freq) -> FinlabDataFrame keep only the last observation per period
lazy lazy() -> LazyWide wrap into a lazy computation handle
reindex reindex(*args, **kwargs) -> FinlabDataFrame pd.DataFrame.reindex that understands quarterly string indexes
align align(other, *args, **kwargs) pd.DataFrame.align that converts a quarterly side to dates first
corrwith corrwith(other, *args, **kwargs) -> pd.Series pairwise correlations with plain pandas alignment and arithmetic

quantile_row(c)

FinlabDataFrame.quantile_row(c: float) -> pd.Series

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

Parameter Type Description
c float Quantile between 0 and 1, e.g. 0.9
close.quantile_row(0.9)  # 90th percentile of stock prices on each date

rebalance(freq)

FinlabDataFrame.rebalance(freq: str | pd.Index | list) -> FinlabDataFrame

Downsamples to the last observation of each period, equivalent to self.resample(freq).last().

Parameter Type Description
freq str, pd.Index or list A pandas frequency string such as 'ME' (month-end), 'W' (weekly) or 'QE' (quarter-end), or explicit dates to select with reindex

lazy()

FinlabDataFrame.lazy() -> LazyWide

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:

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

reindex(), align(), corrwith()

  • 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).
  • corrwith(other, *args, **kwargs): same as pd.DataFrame.corrwith, computed with plain pandas alignment and arithmetic (no forward fill).

Key FinlabDataFrame Methods:

Stock Selection and Ranking

Method Description Example
is_largest(n) Select the N largest values marketcap.is_largest(30)
is_smallest(n) Select the N smallest values pe_ratio.is_smallest(20)
rank() Rank across stocks per date close.rank(axis=1, pct=True)
industry_rank() Ranking within industry close.industry_rank()

Technical Indicators

Method Description Example
average(n) N-period moving average close.average(20)
rise(n) Higher than n periods ago close.rise(3)
fall(n) Lower than n periods ago close.fall(2)
sustain(n) Condition sustained for N periods (close > ma20).sustain(5)

Position Management

Method Description Example
hold_until() Hold until exit condition position.hold_until(close < ma20)
exit_when() Exit on condition position.exit_when(close < ma20)
entry_price() Get entry price (Taiwan market only: reads Taiwan adjusted prices) position.entry_price('close')

Industry Analysis

Method Description Example
neutralize_industry() Industry neutralization factor.neutralize_industry(categories=sectors)
groupby_category() Group by industry (Taiwan market only: Taiwan industry categories) close.groupby_category().mean()

average()

FinlabDataFrame.average(n: int) -> FinlabDataFrame

n-period moving average. A stock gets NaN when fewer than n / 2 values in the window are available.

Parameter Type Description
n positive int Window length

Usage Examples:

from finlab import data

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

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

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

# Moving average crossover strategy
golden_cross = ma20 > ma60

Difference from pandas rolling()

  • average(20) is equivalent to rolling(20, min_periods=10).mean()
  • More concise and readable
  • Return value is still a FinlabDataFrame, allowing method chaining

is_largest() / is_smallest()

FinlabDataFrame.is_largest(n: int) -> FinlabDataFrame
FinlabDataFrame.is_smallest(n: int) -> FinlabDataFrame

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. Quarterly rows are converted to filing dates first, so each date only compares values already public on that date.

Parameter Type Description
n positive int Number of stocks selected per row

Usage Examples:

from finlab import data

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

# Select the 30 smallest by market cap (small-cap stocks)
small_cap = marketcap.is_smallest(30)

# Select the 20 largest by momentum (momentum stocks)
momentum = (close / close.shift(20)).is_largest(20)

# Combine: momentum stocks among small-caps
position = small_cap & momentum

Notes

  • is_largest(n) re-ranks every period (daily/monthly)
  • If fewer than N stocks are available, all are selected
  • Returns a Boolean DataFrame (True = selected)

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

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()

FinlabDataFrame.rank(*args, valid=None, **kwargs) -> FinlabDataFrame

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().

Parameter Type Description
valid boolean DataFrame or Series, optional Only 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

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.

Usage Examples:

from finlab import data

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

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

# Select 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))

industry_rank()

Ranking within industry.

Usage Examples:

from finlab import data

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

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

# Select stocks with an industry percentile score above 0.7
position = industry_rank > 0.7

Industry Classification Source

  • 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'). With categories=None, only top-level categories are used.
  • Ranked per date with rank(axis=1, pct=True) within each category; stocks in multiple categories get the mean percentile.

Classification snapshot, not historical as-of data

Both classifications are the latest snapshot and are applied to every date, so historical rankings may use classifications not yet known at the time. Keep this in mind when backtesting.


rise() / fall()

FinlabDataFrame.rise(n: int = 1) -> FinlabDataFrame
FinlabDataFrame.fall(n: int = 1) -> FinlabDataFrame

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.

Parameter Type Description
n positive int, default 1 Compare with the value n periods earlier

Usage Examples:

from finlab import data

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

# Higher than 3 days ago
rising = close.rise(3)

# Lower than 2 days ago
falling = close.fall(2)

# Rising on each of the last 3 days
rising_3_days = close.rise().sustain(3)

sustain()

Check if a condition is sustained for N periods.

Usage Examples:

from finlab import data

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

# Price stays above 20-day MA for 5 consecutive days
sustained_above_ma = (close > ma20).sustain(5)

# Strategy
position = sustained_above_ma

sustain vs rise

  • rise(n): value is higher than n periods ago
  • sustain(n): condition is True for n consecutive periods (sustain(n, k): at least k times in the last n periods)

hold_until()

Hold position until an exit condition, stop-loss, take-profit, or trailing stop triggers.

Usage Examples:

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,
)

# Backtest
report = sim(position_with_stops, resample='M')
report.display()

Key Concepts

  • hold_until() extends the holding period until the trigger condition
  • Stop-loss/take-profit are calculated based on the entry price
  • trail_stop exits when return falls from the holding-period peak by the configured amount; trail_stop_activation can require a minimum profit before the trailing stop activates

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_mode Dates missing from exit Suited to
'state' (default) Keep the previous row's value until the next row Low-frequency conditions such as quarterly financial statements: once published, the exit holds and the stock is not re-entered
'event' No exit Data 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')

'state' keeps the last row

If the last row of exit is True and no False follows, the stock is never entered again. A single event on one date (for example a DataFrame that lists event dates only) also keeps blocking entries; use exit_mode='event' for such data.

NaN means no exit in both modes. 'state' fills only the dates missing from exit and never replaces a NaN with an earlier True. 'state' needs the exit dates sorted from old to new and otherwise raises ValueError; call exit.sort_index() first.

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 monthly 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()

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'.


exit_when()

Exit when a specific condition is met.

Usage Examples:

from finlab import data

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

# Entry: short MA crosses above long MA
entry = ma20 > ma60

# Exit: short MA crosses below long MA
exit_signal = ma20 < ma60

# Hold until exit signal
position = entry.exit_when(exit_signal)

exit_when vs hold_until

  • exit_when(): Custom exit conditions (e.g., technical indicators)
  • hold_until(): Fixed stop-loss/take-profit percentages

neutralize_industry()

Industry neutralization - removes industry factor effects. Without categories, it uses the Taiwan industry classification (security_categories); for U.S. stocks pass a categories DataFrame with symbol and category columns, for example sectors from us_company_profile.

Usage Examples:

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 neutralized factor
position = neutral_factor.is_largest(30)

Why Neutralize?

  • Some industries naturally trade at higher or lower valuations (e.g., utilities vs. technology)
  • Neutralization avoids over-concentration in a single industry
  • Improves strategy stability

Portfolio Weight Construction df.weight

The df.weight accessor provides methods for constructing and adjusting portfolio weights. All methods return FinlabDataFrame and support method chaining.

Method Overview

Method Description Example
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 volume w.weight.clip_by_volume(1e7, volume=close * volume)
inverse_volatility(window, price) Inverse volatility weighting — low-vol stocks get higher weight w.weight.inverse_volatility(60)
risk_parity(window, price) Risk parity weighting — equal risk contribution per holding w.weight.risk_parity(60)
correlation(window, price, diversify) Adjust weights by correlation — diversify or concentrate w.weight.correlation(diversify=True)
target_volatility(target, window, price) Scale weights to achieve target annualized volatility w.weight.target_volatility(0.15)
limit_turnover(max_turnover) Cap two-way turnover between rebalances w.weight.limit_turnover(0.3)
drawdown_control(max_drawdown, price) Reduce exposure when drawdown exceeds threshold w.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.

Usage Example

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
    .weight.limit_turnover(max_turnover=0.3)  # Turnover cap at 30%
    .weight.drawdown_control(max_drawdown=0.15, price=close)  # Drawdown control at 15%
)

report = sim(weighted, resample='M')
report.display()

Method ordering

  • Apply weighting first (inverse_volatility / risk_parity), then constraints (clip_by_volume, or cap_industry for Taiwan stocks)
  • limit_turnover and drawdown_control are typically applied last

Cross-Sectional Transforms df.cs

The df.cs (cross-sectional) accessor provides transforms computed across all stocks on each date.

Method Overview

Method Description Example
rank() Percentile rank (0, 1] factor.cs.rank()
winsorize(lower, upper) Percentile winsorization factor.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 cross-sectional mean factor.cs.demean()

Usage Example

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  # Select top 20%

Sector-Relative Transforms & Aggregation df.sector

The df.sector accessor provides two categories of functionality. 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).

  1. Within-sector transforms: 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 — sector-level statistics

Transform Methods

Method Description Example
rank() Within-sector percentile rank factor.sector(sectors).rank()
winsorize(lower, upper) Within-sector winsorization factor.sector(sectors).winsorize(0.05, 0.95)
bucket(n) Within-sector bucketing factor.sector(sectors).bucket(3)
zscore() Within-sector standardization factor.sector(sectors).zscore()
demean() Within-sector demeaning factor.sector(sectors).demean()

Aggregation Methods

Method Description Example
mean() Sector mean roe.sector(sectors).mean()
std(ddof=1) Sector standard deviation roe.sector(sectors).std()
median() Sector median roe.sector(sectors).median()
sum() Sector sum revenue.sector(sectors).sum()
min() Sector minimum close.sector(sectors).min()
max() Sector maximum close.sector(sectors).max()
count() Count of non-NaN values in sector roe.sector(sectors).count()

Usage Example

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()

# Compare against sector average
sector_avg_roe = roe.sector(sectors).mean()
above_avg = roe > sector_avg_roe  # Stocks above sector average

sector vs neutralize_industry()

  • df.sector.zscore() achieves the same effect as neutralize_industry() (industry neutralization)
  • df.sector offers more flexibility: ranking, bucketing, aggregation, and more

Complete Example: 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%}")

FAQ

Q: How does FinlabDataFrame automatically align different frequency data?

from finlab import data

data.set_market('us')

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

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

# Direct operations auto-align (revenue is forward-filled to daily frequency)
position = (close > 100) & (revenue > 1e9)
# position is daily data, revenue has been auto-filled

Alignment Rules: - 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)

Q: 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.

from finlab.dataframe import FinlabDataFrame

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).

Q: 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)

Q: What is the difference between is_largest() and rank() + threshold?

from finlab import data

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

# Method 1: is_largest (fixed count)
position1 = close.is_largest(30)  # Always selects 30 stocks

# Method 2: rank + threshold (fixed percentage)
position2 = close.rank(pct=True, axis=1) > 0.8  # Selects top 20% (count varies)

# Difference:
# - Method 1: Fixed number of holdings (suitable for fixed capital allocation)
# - Method 2: Variable number of holdings (adapts to market conditions)

Q: How does 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,
)

Q: How do I use pandas methods on FinlabDataFrame?

from finlab import data

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

# FinlabDataFrame is fully compatible with pandas
close.rolling(20).mean()  # pandas method
close.fillna(0)  # pandas method
close.dropna()  # pandas method

# All pandas methods work
# Results remain FinlabDataFrame (retaining extended features)

Resources

Batch industry neutralization and alignment profiling

neutralize_industry_batch() accepts a factor list or mapping. Factors with identical dates and symbols share category mapping, date conversion, and vectorized industry passes; the output preserves the input container shape.

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())

categories works 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().