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
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:
- It has extra methods such as
is_largest()andsustain(). - 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)
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 |
rebalance(freq)
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()
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 aspd.DataFrame.reindex, except that reindexing a quarterly (2024-US-Q3) frame onto dates first converts the quarters to filing dates withindex_str_to_date().df.reindex(close.index, method='ffill')therefore gives each stock's latest disclosed value instead of allNaNor aTypeError.align(other, *args, **kwargs): same aspd.DataFrame.align, converting a quarterly side to dates first. Returns a tuple(left, right).corrwith(other, *args, **kwargs): same aspd.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()
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 torolling(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()
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
industrycolumn ofdata.get('us_company_profile')(USMarket.get_industry()).categories=[...]restricts the ranking to the listed industries. - For Taiwan stocks (
tw_stockandrotc_stock), classifications come from the TPEx industry value chain viadata.get('security_industry_themes'). Withcategories=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 agosustain(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_stopexits when return falls from the holding-period peak by the configured amount;trail_stop_activationcan 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(fromdata.get(..., lazy=True)or.lazy()): the lazy engine forward-fills it onto the lazy timeline before callinghold_until, soexit_mode='event'silently gives the same result as'state'. For event semantics, pass an eagerexit, 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_turnoveranddrawdown_controlare 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).
- Within-sector transforms: Same 5 methods as
df.cs(rank / winsorize / bucket / zscore / demean), computed within each sector - 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 asneutralize_industry()(industry neutralization)df.sectoroffers 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
- Stock Selection Tutorial - FinlabDataFrame practical applications
- Backtesting Tutorial - Complete backtesting workflow
- Technical Indicator Selection Examples - Strategy examples
- pandas Official Documentation - pandas fundamentals
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().