Docs
Open Studio

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

ModulePurposeUse case
MaeMfeAnalysisVolatility analysis (MAE/MFE)Optimizing stop-loss/take-profit, understanding strategy characteristics
PeriodStatsAnalysisPeriod stability analysisReviewing yearly, monthly, and recent performance
InequalityAnalysisFactor threshold analysisChecking whether filtering trades on a factor improves returns
AlphaBetaAnalysisAlpha/Beta analysisMeasuring excess return and market correlation
DrawdownAnalysisDrawdown analysisUnderstanding max drawdown timing and magnitude
LiquidityAnalysisLiquidity 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')
Backtest report · interactive Open full size

Read the 12 subplots

RowSubplotHow to read it
11. Win Ratio: trade win rate distribution (profit vs loss histogram)Blue: profitable trades, pink: losing trades; green dashed line: average return
12. Edge Ratio: MFE/MAE ratio at different holding periodsA value > 1 means profit magnitude exceeds loss magnitude; shows the strategy's "edge time window"
13. MAE vs Return: relationship between MAE and final returnHorizontal dashed line: MAE 75th percentile (Q3); used for setting stop-loss points
24. GMFE vs MAE: maximum profit vs maximum lossQ3 dashed lines: range of 75% of trades' GMFE and MAE; used for evaluating take-profit potential
25. BMFE vs MAE: maximum profit before MAE occurredHigher values mean more opportunity to take profit before the stop-loss triggers; used for optimizing trailing stops
26. MDD vs GMFE: maximum drawdown vs maximum profitOrange line: MDD = GMFE baseline. Points below: GMFE > MDD (good signal, trailing stop viable); points above: MDD > GMFE (bad signal, missing profits)
37-9. MAE/BMFE/GMFE Distribution: distribution and density of each metricHistogram + density curve; blue: profitable trades, pink: losing trades; Q3 dashed line: 75th percentile
410. Indices Stats (Violin Plot): statistical distribution of 6 key metricsThe 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.2

Take-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.8

Whether 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:

  1. 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').
  2. Yearly Stats: a heatmap of the same 7 metrics per year.
  3. 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))
Backtest report · interactive Open full size

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:

  1. 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%.
  2. Yearly alpha/beta: annual alpha and beta breakdown to observe strategy stability.
  3. 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:

  1. drawdown: the drawdown series (index and values)
  2. longest_drawdown: the 5 longest drawdowns, keyed by start date, with End, Length (days) and drawdown (depth)
  3. 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 itemDescription
buy_highTrades buying at or near the upper price limit
sell_lowTrades selling at or near the lower price limit
low_volume_stocksTrades with fewer shares traded than required_volume
low_turnover_stocksTrades with less value traded than required_turnover
Alert / disposition / full-delivery stocksTrades 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)
        """
        pass

Run 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 typeKey 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 stocksLiquidityAnalysis (avoid liquidity risk)
Market neutral strategiesAlphaBetaAnalysis (confirm Beta close to 0)
High-frequency tradingDrawdownAnalysis (confirm drawdowns are manageable)

Decide what to do next

Check in order, and deal with the first "yes":

CheckIf 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

↑↓ Move Enter Open Esc Close