Guides
Analysis modules
On this page11
Cumulative return and Sharpe ratio alone miss important risk dimensions. After a backtest, run the six built-in tools of finlab.analysis with report.run_analysis() to check volatility, period stability and liquidity risk.
Which module to use
| Module | Purpose | Use case |
|---|---|---|
| MaeMfeAnalysis | Volatility analysis (MAE/MFE) | Optimizing stop-loss/take-profit, understanding strategy characteristics |
| PeriodStatsAnalysis | Period stability analysis | Reviewing yearly, monthly, and recent performance |
| InequalityAnalysis | Factor threshold analysis | Checking whether filtering trades on a factor improves returns |
| AlphaBetaAnalysis | Alpha/Beta analysis | Measuring excess return and market correlation |
| DrawdownAnalysis | Drawdown analysis | Understanding max drawdown timing and magnitude |
| LiquidityAnalysis | Liquidity risk assessment (Taiwan market only) | Large capital strategies, low-liquidity assets |
Run an analysis
Every analysis module is called the same way: finish a backtest, then pass the module name (a string) or a module instance to report.run_analysis(), followed by the module's parameters.
from finlab import data
from finlab.backtest import sim
# Complete backtest
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
position = close > close.average(20)
report = sim(position, resample='M')
# Run analysis - Method 1: Using module name (string)
report.run_analysis('MaeMfeAnalysis', violinmode='group')
# Run analysis - Method 2: Using module instance
from finlab.analysis.maeMfeAnalysis import MaeMfeAnalysis
report.run_analysis(MaeMfeAnalysis(violinmode='group'))Method 1 is recommended: passing the module name as a string is more concise, and FinLab handles module loading automatically.
Set stops from MAE/MFE
MAE/MFE analysis is the core tool for optimizing stop-loss and take-profit. Its 12 subplots show each trade's volatility profile:
- MAE (Maximum Adverse Excursion): maximum adverse movement during the holding period (max loss)
- BMFE (Before-MAE MFE): maximum favorable movement before MAE occurred
- GMFE (Global MFE): maximum favorable movement during the holding period (max profit)
- MDD (Maximum Drawdown): maximum drawdown during the holding period
MaeMfeAnalysis(
violinmode='group', # Violin plot mode: 'group' or 'overlay'
mfe_scatter_x='mae', # MFE scatter plot X-axis: 'mae' or 'return'
)The chart below is the MAE/MFE analysis of the quick-start strategy from Backtesting. report.display_mae_mfe_analysis() is a shortcut for run_analysis():
from finlab import data
from finlab import backtest
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
# Hold the 20 S&P 500 stocks with the strongest 6-month return
position = (close / close.shift(126)).is_largest(20)
# Backtest, rebalance at month-end (M)
report = backtest.sim(position, resample='M', name='S&P 500 momentum top 20')
# Method 1: Using the display_mae_mfe_analysis() shortcut
report.display_mae_mfe_analysis()
# Method 2: Using run_analysis()
report.run_analysis('MaeMfeAnalysis', violinmode='group')Read the 12 subplots
| Row | Subplot | How to read it |
|---|---|---|
| 1 | 1. Win Ratio: trade win rate distribution (profit vs loss histogram) | Blue: profitable trades, pink: losing trades; green dashed line: average return |
| 1 | 2. Edge Ratio: MFE/MAE ratio at different holding periods | A value > 1 means profit magnitude exceeds loss magnitude; shows the strategy's "edge time window" |
| 1 | 3. MAE vs Return: relationship between MAE and final return | Horizontal dashed line: MAE 75th percentile (Q3); used for setting stop-loss points |
| 2 | 4. GMFE vs MAE: maximum profit vs maximum loss | Q3 dashed lines: range of 75% of trades' GMFE and MAE; used for evaluating take-profit potential |
| 2 | 5. BMFE vs MAE: maximum profit before MAE occurred | Higher values mean more opportunity to take profit before the stop-loss triggers; used for optimizing trailing stops |
| 2 | 6. MDD vs GMFE: maximum drawdown vs maximum profit | Orange line: MDD = GMFE baseline. Points below: GMFE > MDD (good signal, trailing stop viable); points above: MDD > GMFE (bad signal, missing profits) |
| 3 | 7-9. MAE/BMFE/GMFE Distribution: distribution and density of each metric | Histogram + density curve; blue: profitable trades, pink: losing trades; Q3 dashed line: 75th percentile |
| 4 | 10. Indices Stats (Violin Plot): statistical distribution of 6 key metrics | The metrics are return, mae, bmfe, gmfe, mdd and pdays_ratio; group mode shows profit/loss trades separately (recommended), overlay mode overlays all trades |
Set stops from the results
Stop-loss: look at the MAE 75th percentile (Q3) and set the stop slightly wider, to avoid over-stopping.
# 1. Check MAE 75th percentile (Q3)
trades = report.get_trades()
mae_q3 = trades['mae'].quantile(0.75)
# 2. Set stop-loss slightly wider than Q3 to avoid over-stopping
stop_loss = abs(mae_q3) * 1.2Take-profit: look at the GMFE 75th percentile and set the target a little below it, so most profits are captured.
# Check GMFE 75th percentile
gmfe_q3 = trades['gmfe'].quantile(0.75)
# Set take-profit a little below Q3, ensuring most profits are captured
take_profit = gmfe_q3 * 0.8Whether to use a trailing stop: check Missed Win-profits PCT in the "MDD vs GMFE" chart.
- Missed Win-profits PCT < 20%: a trailing stop is suitable
- Missed Win-profits PCT > 40%: a trailing stop is not suitable (exits too early, missing larger gains)
# Use trailing stop (via sim()'s trail_stop parameter)
entries = close > close.average(20)
exits = close < close.average(60)
position = entries.hold_until(exit=exits, stop_loss=0.1)
# Signal after an 8 percentage-point drop from peak return
report = sim(position, resample='M', trail_stop=0.08)Check performance across periods
PeriodStatsAnalysis analyzes strategy performance across different periods (yearly, monthly, recent) and compares it with the benchmark (the S&P 500 for U.S. stocks) to verify stability.
report.run_analysis('PeriodStatsAnalysis')It produces several tables:
- Overall Stats: one column for the benchmark and one for the strategy. Each metric (
calmar_ratio,sortino_ratio,sharpe_ratio,profit_factor,tail_ratio,return,volatility) is computed from daily, monthly and yearly returns, in rows such as('overall_daily', 'sharpe_ratio')and('overall_monthly', 'return'). - Yearly Stats: a heatmap of the same 7 metrics per year.
- Recent Stats: performance for M (month), Q (quarter), HY (half-year), Y (year), 3Y (three years).
How to judge:
- Strategy sharpe_ratio < benchmark: risk-adjusted return is worse than the market; improvement needed
- Some years perform very poorly: check whether specific market conditions were encountered (e.g., a bear market)
- Recent performance significantly declined: the strategy may be failing; re-optimization needed
Check whether a factor picks better trades
InequalityAnalysis checks how trade returns change if you only keep trades whose factor value was below or above a threshold x, for every threshold. Trades are sorted by the factor value on the entry signal date. The upper charts show the mean return of the trades kept by each threshold minus the mean of all trades (<name> < x on the left, <name> > x on the right); the lower charts show the factor value at each cut against the number of trades.
class InequalityAnalysis(
name, # Factor name: the trade column and chart label
df=None, # Factor values (dates by symbols); if None, data.get(name) is fetched
date_type='entry_sig_date', # 'entry_date', 'entry_sig_date', 'exit_date' or 'exit_sig_date'
target='return', # Any column of report.get_trades()
)Do larger S&P 500 companies make better trades for a simple trend strategy?
from finlab import data
from finlab.analysis.inequalityAnalysis import InequalityAnalysis
from finlab.backtest import sim
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
market_value = data.get('etl:us_market_value')
position = close > close.average(20)
report = sim(position, resample='M')
report.run_analysis(InequalityAnalysis(name='market_value', df=market_value))How to judge:
- A curve that rises steadily as the threshold moves: the factor separates good trades from bad ones; consider adding it as a filter
- A flat curve near zero: filtering on this factor does not change returns
- Thresholds that keep only a handful of trades: results are noisy; check the trade count in the lower charts before acting
Measure alpha and beta
AlphaBetaAnalysis measures strategy performance relative to a benchmark index:
- Alpha: excess return (the strategy's unique value)
- Beta: market sensitivity (correlation with the market)
report.run_analysis('AlphaBetaAnalysis')It shows three sections:
- Overall summary: alpha and beta for the whole backtest. Alpha is the annualized excess return (true contribution after removing market risk); beta is market sensitivity: a beta of 0.8 means the strategy is expected to rise 0.8% when the market rises 1%.
- Yearly alpha/beta: annual alpha and beta breakdown to observe strategy stability.
- Recent alpha/beta: performance for the full period and the recent 1M, 3M, 6M and 1Y.
To get the raw data (a dict), pass display=False:
result = report.run_analysis('AlphaBetaAnalysis', display=False)
# result['overall'] -> {'alpha': ..., 'beta': ...}
# result['yearly'] -> {'alpha': [...], 'beta': [...], 'year': [...]}
# result['recent'] -> {'alpha': [...], 'beta': [...], 'ndays': [...]}How to judge:
- Alpha > 0: the strategy has excess returns; worth executing
- Alpha < 0: the strategy underperforms the benchmark; improvement needed
- Beta > 1.5: strategy volatility is extremely high; high risk
- Beta < 0: the strategy is negatively correlated with the market (suitable for hedging)
See how deep and long drawdowns are
DrawdownAnalysis lists the drawdowns of the strategy and of its benchmark:
- The drawdown series over time
- The 5 longest drawdowns
- The 5 deepest drawdowns
result = report.run_analysis('DrawdownAnalysis')The result is a dict with a strategy and a benchmark entry, each containing:
drawdown: the drawdown series (indexandvalues)longest_drawdown: the 5 longest drawdowns, keyed by start date, withEnd,Length(days) anddrawdown(depth)largest_drawdown: the 5 deepest drawdowns, in the same format
How to judge:
- Max drawdown > -50%: risk is too high; additional risk controls needed
- Drawdown duration > 1 year: the strategy may be ineffective long-term
- Frequent recent drawdowns: market conditions may have changed; re-evaluate the strategy
Check liquidity risk (Taiwan only)
LiquidityAnalysis detects whether the strategy runs into liquidity problems at entry or exit:
- Insufficient volume: daily trading volume below requirements
- Insufficient turnover: daily trading value below requirements
- Price limits: buying at the upper limit or selling at the lower limit (unable to execute)
- Alert/disposition/full-delivery stocks: high-risk securities with trading restrictions
class LiquidityAnalysis(
required_volume=200000, # Required daily trading volume (default: 200,000 shares)
required_turnover=1000000, # Required daily turnover (default: 1,000,000)
)# Assess the liquidity risk of a Taiwan stock strategy
report.run_analysis('LiquidityAnalysis', required_volume=100000)The table has an entry and an exit row; each column is the share of trades with that risk:
| Risk item | Description |
|---|---|
| buy_high | Trades buying at or near the upper price limit |
| sell_low | Trades selling at or near the lower price limit |
| low_volume_stocks | Trades with fewer shares traded than required_volume |
| low_turnover_stocks | Trades with less value traded than required_turnover |
| Alert / disposition / full-delivery stocks | Trades in securities under these Taiwan trading restrictions |
If you find problems:
- Insufficient liquidity: add screening conditions (e.g., "volume > 1,000,000 shares")
- Frequent price limit hits: adjust entry/exit timing (e.g., use the open price instead of the close)
- Too many alert/disposition stocks: add risk control conditions to exclude high-risk securities
Write your own analysis module
If the built-in analyses are not enough, inherit the Analysis class to build your own.
Basic example: returns by market value
Average trade return by company size at the entry signal date:
import pandas as pd
from finlab import data
from finlab.analysis import Analysis
class MarketValueAnalysis(Analysis):
def calculate_trade_info(self, report):
"""Add each trade's market value on its entry signal date"""
return [['market_value', data.get('etl:us_market_value'), 'entry_sig_date']]
def analyze(self, report):
"""Average return per market-value quintile"""
trades = report.get_trades()
quintile = pd.qcut(trades['market_value@entry_sig_date'], 5, labels=False) + 1
self.result = trades.groupby(quintile)['return'].agg(['count', 'mean'])
return self.result.to_dict()
def display(self):
"""Display analysis results"""
return self.result
# Use custom analysis
report.run_analysis(MarketValueAnalysis())calculate_trade_info() runs before analyze() and adds the extra information to the report.get_trades() DataFrame. For example, returning [['market_value', market_value, 'entry_sig_date']] adds a 'market_value@entry_sig_date' column to trades.
Advanced example: win rate by month
Analyze whether win rates differ by month:
import pandas as pd
from finlab.analysis import Analysis
class MonthlyWinRateAnalysis(Analysis):
def analyze(self, report):
trades = report.get_trades()
trades['entry_month'] = pd.to_datetime(trades['entry_date']).dt.month
monthly_stats = (
trades.groupby('entry_month')
.agg({'return': ['count', lambda x: (x > 0).mean(), 'mean']})
.round(3)
)
monthly_stats.columns = ['Trade Count', 'Win Rate', 'Average Return']
self.result = monthly_stats
return self.result.to_dict()
def display(self):
import plotly.express as px
fig = px.bar(self.result, y='Win Rate', title='Monthly Win Rate Analysis')
return fig
# Usage
report.run_analysis(MonthlyWinRateAnalysis())Methods of the Analysis class
When inheriting the Analysis class, you can override these methods:
class Analysis:
def is_market_supported(self, market):
"""Check if the market is supported
Returns:
bool: True if supported, False otherwise
"""
return True
def calculate_trade_info(self, report):
"""Calculate additional trade information
Returns:
list: Format is [[field_name, data DataFrame, date column used], ...]
Examples:
return [
['market_value', market_value, 'entry_sig_date'],
['volume', volume, 'entry_date'],
]
"""
return []
def analyze(self, report):
"""Execute analysis logic
Args:
report: Backtest report object
Returns:
dict: Analysis results (for cloud upload)
"""
pass
def display(self):
"""Display analysis results
Returns:
Visualization object (Plotly Figure or Pandas Styler recommended)
"""
passRun the usual analyses together
from finlab import data
from finlab.backtest import sim
# Complete backtest
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
position = close > close.average(20)
report = sim(position, resample='M')
# Basic performance
report.display()
# 1. Volatility analysis
report.display_mae_mfe_analysis()
# 2. Period stability analysis
report.run_analysis('PeriodStatsAnalysis')
# 3. Alpha/Beta analysis
report.run_analysis('AlphaBetaAnalysis')
# 4. Custom market value analysis
report.run_analysis(MarketValueAnalysis())
# Taiwan stocks only: liquidity risk (check for large capital)
# report.run_analysis('LiquidityAnalysis', required_volume=100000)Focus by strategy type
| Strategy type | Key analysis module |
|---|---|
| Short-term trading (intraday, weekly) | MaeMfeAnalysis (optimize stop-loss/take-profit) |
| Long-term investing (monthly, quarterly) | PeriodStatsAnalysis (check long-term stability) |
| Large capital strategies on Taiwan stocks | LiquidityAnalysis (avoid liquidity risk) |
| Market neutral strategies | AlphaBetaAnalysis (confirm Beta close to 0) |
| High-frequency trading | DrawdownAnalysis (confirm drawdowns are manageable) |
Decide what to do next
Check in order, and deal with the first "yes":
| Check | If yes |
|---|---|
| Liquidity risk > 20%? | Add volume screening conditions |
| Sharpe ratio < 1? | Optimize screening conditions or stop-loss/take-profit |
| Max drawdown > -30%? | Add risk controls (stop-loss, position sizing) |
| Alpha < 0? | The strategy has no excess return; redesign it |
When every check passes, proceed to out-of-sample testing.
Next steps
- Backtesting Every
sim()parameter and the backtest report. - Strategy optimization Backtest every combination of conditions, then examine the best ones with these analyses.
- Complete strategy workflow The full path from idea to live strategy.
- finlab.analysis Full parameters of each analysis module.
Was this page helpful?