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 to | Use |
|---|---|
| Combine daily, monthly and quarterly data | Plain + - * / > < & | operators: they align automatically |
| Compute an n-period moving average | average() |
| Check whether a value is higher or lower than n periods ago | rise() / fall() |
| Require a condition to hold for n periods | sustain() |
| Pick the n largest or smallest stocks each day | is_largest() / is_smallest() |
| Rank across stocks each day | rank(axis=1, pct=True) or df.cs.rank() |
| Rank within each industry | industry_rank() or df.sector(...).rank() |
| Hold until an exit condition, stop-loss or take-profit | hold_until() |
| Exit when a condition is met | exit_when() |
| Get the price of each entry (Taiwan market only) | entry_price() |
| Remove industry effects (industry neutralization) | neutralize_industry() |
| Compute statistics per industry | df.sector(...).mean() and friends, or groupby_category() (Taiwan market only) |
| Winsorize, standardize or bucket a factor | df.cs |
| Build portfolio weights | df.weight |
| Keep only the last row of each month, week or quarter | rebalance() |
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:
- It has extra methods such as
is_largest()andsustain(). - 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| Parameter | Type | Default | Description |
|---|---|---|---|
n | positive int | required | Window 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)| Parameter | Type | Default | Description |
|---|---|---|---|
n | positive int | 1 | Compare 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)| Parameter | Type | Default | Description |
|---|---|---|---|
nwindow | int | required | Number of periods to look back |
nsatisfy | int or None | None | Minimum 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| Parameter | Type | Default | Description |
|---|---|---|---|
n | positive int | required | Number 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))| Parameter | Type | Default | Description |
|---|---|---|---|
valid | boolean DataFrame or Series | None | 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 |
*args, **kwargs | Passed 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| Parameter | Type | Default | Description |
|---|---|---|---|
categories | list[str] or None | None | Industries to rank within. None uses all industries (for Taiwan stocks, only top-level categories) |
- 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'). - 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_stopexits when the return falls from the holding-period peak by the configured amount;trail_stop_activationcan require a minimum profit before the trailing stop activates. See the common questions for how the trailing stop is computed.
| Parameter | Type | Default | Description |
|---|---|---|---|
exit | DataFrame | required | Exit signal. A quarterly string index (such as 2024-US-Q1) is converted to filing dates first |
nstocks_limit | int or None | None | Maximum number of stocks held at once; None means no limit |
stop_loss | float or DataFrame | -np.inf | Exit 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_profit | float or DataFrame | np.inf | Exit when the price rises this far above cost; same rules as stop_loss |
trail_stop | float | np.inf | Trailing stop size; np.inf disables it |
trail_stop_activation | float or None | 0 | Profit required before the trailing stop activates; None is the same as 0 |
trade_at | str | 'close' | Reference price for the stops: 'close' or 'open' |
rank | DataFrame or None | None | When entries exceed nstocks_limit, stocks with larger values enter first |
exit_mode | str | '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_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')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(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()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)| Parameter | Type | Default | Description |
|---|---|---|---|
exit | DataFrame | required | Exit 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')| Parameter | Type | Default | Description |
|---|---|---|---|
trade_at | str | '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)| Parameter | Type | Default | Description |
|---|---|---|---|
categories | DataFrame or None | None | A 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_const | bool | True | Add 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())| Parameter | Type | Default | Description |
|---|---|---|---|
factors | list or dict | required | Factors to neutralize |
categories | DataFrame or None | None | Same as in neutralize_industry() |
add_const | bool | True | Same 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%| Method | Description | Example |
|---|---|---|
rank() | Percentile rank, in (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 the cross-sectional mean | factor.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:
- Within-sector transforms: the 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, 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| 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() |
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 the sector | roe.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()| 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 a target annualized volatility | w.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 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.
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.
| 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 that warns on time-axis ranking, 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 |
where / mask | where(cond, *args, **kwargs), mask(cond, *args, **kwargs) | pd.DataFrame.where / mask that convert a quarterly frame to dates first |
corrwith | corrwith(other, *args, **kwargs) -> pd.Series | pairwise correlations with plain pandas alignment and arithmetic |
fillna | fillna(value=None, **kwargs) | fills as pandas does, and keeps a boolean table boolean when value is a bool |
rolling | rolling(*args, **kwargs) | pd.DataFrame.rolling; numeric frames compute their columns concurrently |
pct_change | pct_change(periods=1, fill_method=None, *args, **kwargs) -> FinlabDataFrame | fractional 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| Parameter | Type | Default | Description |
|---|---|---|---|
c | float | required | Quantile 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| Parameter | Type | Default | Description |
|---|---|---|---|
freq | str, pd.Index or list | required | 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.
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 valuereindex(*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).where(cond, ...)/mask(cond, ...): with a date-indexedcond,roe.where(cond)works likeroe.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 aspd.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 datePut 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 percentageHow 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
- Computing stock-selection conditions Rank, combine and set entries and exits with FinlabDataFrame in practice.
- Backtesting Hand your conditions to
sim(): rebalance frequency, stops and trading costs. - Technical indicator selection Turn moving averages, RSI and other indicators into a full strategy.
- Research to production From writing conditions and backtesting to daily updates.
For pandas itself, see the pandas documentation.
Was this page helpful?