Docs
Open Studio

Guides

Strategy optimization

On this page9

How do you find the best combination of screening conditions? finlab.optimize.sim_conditions() backtests every combination and compares the results visually.

When developing trading strategies, you often have several screening conditions:

  • Technical: moving average crossovers, new highs, RSI indicators
  • Fundamental: P/E ratio, revenue growth, profitability
  • Size and liquidity: market value, dollar volume

Each condition alone may produce mediocre performance, but combining several may yield unexpected results. Testing every combination by hand is extremely time-consuming; sim_conditions() automates it.

Backtest every combination at once

Put the conditions in a dictionary keyed by name and pass it to sim_conditions():

from finlab import data
from finlab.backtest import sim
from finlab.optimize.combinations import sim_conditions

data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')
    revenue = data.get('us_income_statement:revenue')  # Quarterly
    operating_income = data.get('us_income_statement:operating_income')  # Quarterly

# Define 3 conditions
c1 = (close > close.average(20)) & (close > close.average(60))  # Technical: MA breakout
c2 = operating_income.pct_change(4) > 0  # Fundamental: profit growth (year over year)
c3 = (
    revenue.pct_change(4) > 0.1
)  # Fundamental: revenue growth above 10% (year over year)

# Set exit condition
exits = close < close.average(20)

# Conditions dictionary, keys are condition names
conditions = {'c1': c1, 'c2': c2, 'c3': c3}

# Test all combinations
report_collection = sim_conditions(
    conditions=conditions,
    hold_until={'exit': exits, 'stop_loss': 0.1},  # Exit when below MA, 10% stop-loss
    resample='M',  # Monthly rebalancing
    position_limit=0.1,  # Max 10% per stock
    upload=False,  # Don't upload to cloud
)

hold_until takes the arguments of the entry/exit signal helper; every other argument (resample, position_limit, upload and so on) is passed to sim() as is.

This code automatically tests the following 7 combinations:

  1. c1 (technical only)
  2. c2 (profit growth only)
  3. c3 (revenue growth only)
  4. c1 & c2 (technical + profit growth)
  5. c1 & c3 (technical + revenue growth)
  6. c2 & c3 (profit growth + revenue growth)
  7. c1 & c2 & c3 (all three conditions)

Compare combinations with charts

The charts below are sample output from a five-condition run (c1 to c5); your figures depend on your conditions and data.

Cumulative return line chart

Compare the equity curves of each combination:

report_collection.plot_creturns().show()
Cumulative Returns
Cumulative Returns

Grouped bar chart of metrics

report_collection.plot_stats('bar').show()
Bar Chart
Bar Chart

Metric ranking heatmap

The heatmap is returned as a styled table that displays directly in a notebook, without .show():

report_collection.plot_stats('heatmap')
Heatmap
Heatmap

The heatmap uses color intensity to represent the percentile ranking (0-100%) of each metric. Higher values indicate better rankings:

  • avg_score: average score across all metrics; higher scores indicate better overall performance
  • Sorted by avg_score in descending order by default, with the best combination at the top
  • Brighter colors (yellow) indicate higher rankings for that metric

Show only some metrics or change the sort

indicators picks the metrics to show, and heatmap_sort_by picks the heatmap's sort column:

# Show only return, Sharpe ratio, and max drawdown
report_collection.plot_stats(
    'bar', indicators=['cagr', 'daily_sharpe', 'max_drawdown']
).show()

# Sort heatmap by Sharpe ratio
report_collection.plot_stats('heatmap', heatmap_sort_by='daily_sharpe')

# Sort heatmap by multiple metrics
report_collection.plot_stats('heatmap', heatmap_sort_by=['daily_sharpe', 'win_ratio'])

Get detailed performance metrics

stats = report_collection.get_stats()
print(stats)

Returns a DataFrame with 12 metrics:

Strategy-level metrics

MetricDescriptionDirection
cagrAnnualized return (compound, CAGR)Higher is better
daily_sharpeAnnualized Sharpe ratio (risk-adjusted return)Higher is better
daily_sortinoAnnualized Sortino ratio (downside risk-adjusted return)Higher is better
max_drawdownMaximum drawdown (negative value)Smaller absolute value is better
avg_drawdownAverage drawdown (negative value)Smaller absolute value is better
ytdStrategy return so far this yearHigher is better

Trade-level metrics

MetricDescriptionDirection
win_ratioWin rate per tradeHigher is better
avg_returnAverage profit per tradeHigher is better
avg_maeAverage MAE per trade (negative value)Smaller absolute value is better
avg_bmfeAverage BMFE (MFE before MAE) per tradeHigher is better
avg_gmfeAverage global MFE per tradeHigher is better
avg_mddAverage max drawdown per trade (negative value)Smaller absolute value is better

Get one combination's backtest report

report_collection.reports is a dict keyed by combination name:

print(report_collection.reports.keys())
# Output: dict_keys(['c1', 'c2', 'c3', 'c1 & c2', 'c1 & c3', ...])

# Get a specific combination's report
report = report_collection.reports['c1 & c3']
report.display()

Advanced example: five conditions

Five conditions yield 31 combinations (2^5 - 1):

from finlab import data
from finlab.optimize.combinations import sim_conditions

data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')
    raw_close = data.get('price:close')  # unadjusted price for valuation
    eps = data.get('us_income_statement:eps_diluted')
    revenue = data.get('us_income_statement:revenue')

pe = raw_close / eps.rolling(4).sum()  # Trailing-12-month P/E
revenue_ma2 = revenue.average(2)
revenue_ma4 = revenue.average(4)

# 5 conditions
c1 = (close > close.average(20)) & (close > close.average(60))  # MA bullish alignment
c2 = close == close.rolling(20).max()  # 20-day new high
c3 = (pe > 0) & (pe < 15)  # Low P/E
c4 = (
    revenue_ma2 / revenue_ma4 > 1.05
)  # Revenue acceleration (last 2 vs. last 4 quarters)
c5 = revenue / revenue.shift(1) > 0.9  # Quarterly revenue change > -10%

exits = close < close.average(20)

conditions = {'c1': c1, 'c2': c2, 'c3': c3, 'c4': c4, 'c5': c5}

report_collection = sim_conditions(
    conditions=conditions,
    hold_until={'exit': exits, 'stop_loss': 0.1},
    resample='M',
    position_limit=0.1,
    upload=False,
)

# View heatmap to quickly find the best combination
report_collection.plot_stats('heatmap')

Suppose the heatmap shows c1 & c3 & c4 has the highest avg_score. Next:

  1. Check the cumulative return curve: confirm steady NAV growth
  2. Check key metrics:
    • Sharpe ratio > 1.5? (Is risk-adjusted return sufficient?)
    • Max drawdown < -30%? (Can you tolerate the worst case?)
    • Win rate > 50%? (Is the trade win rate reasonable?)
  3. Analyze what the combination means: c1 & c3 & c4 represents "technical breakout + cheap valuation + revenue growth", a combination with clear business logic

Test stops at the same time

The hold_until parameter of sim_conditions() supports various exit logics:

# 1. Exit signal only
hold_until = {'exit': exits}

# 2. Exit signal + stop-loss
hold_until = {'exit': exits, 'stop_loss': 0.1}

# 3. Exit signal + take-profit
hold_until = {'exit': exits, 'take_profit': 0.2}

# 4. Exit signal + stop-loss + take-profit
hold_until = {'exit': exits, 'stop_loss': 0.1, 'take_profit': 0.2}

# 5. Exit signal + stop-loss (trailing stop requires trail_stop in sim())
hold_until = {'exit': exits, 'stop_loss': 0.1}
# Combined with: sim_conditions(..., trail_stop=0.15)

A practical workflow

Define candidate conditions

Prepare 5 to 8 conditions.

Backtest each condition alone

Confirm each condition has basic performance.

Run sim_conditions()

Test all combinations.

Analyze the charts

  • Use the heatmap to find the top 3 combinations
  • Use the line chart to confirm NAV stability
  • Use the bar chart to compare key metrics

Analyze the top 3 in detail

  • Run MAE/MFE analysis
  • Check liquidity (average dollar volume of the holdings)
  • Run out-of-sample tests

Select the final strategy

Weigh performance, risk, and logical soundness.

FAQ

Too many combinations, and it takes too long?

With more than 6 conditions, there are more than 63 combinations. Suggestions:

  1. Pre-filter important conditions: use single-condition backtests to remove poorly performing conditions
  2. Test in batches: separate conditions into technical and fundamental groups
  3. Use a longer resample: switch to resample='Q' (quarterly) to reduce computation
How do I avoid overfitting?
  1. Out-of-sample testing: optimize on historical data, validate on recent data
  2. Avoid too many conditions: be especially careful with more than 5 conditions
  3. Check combination logic: does the best combination have business logic behind it?
  4. Evaluate multiple metrics: don't just look at returns; also consider Sharpe ratio, drawdown, and win rate
What if conditions have different data frequencies?

FinLab aligns quarterly data to its SEC filing date and forward-fills automatically:

data.set_market('us')

# Daily data
close = data.get('price:adj_close')  # Updated daily

# Quarterly data, aligned to each SEC filing date
revenue = data.get('us_income_statement:revenue')
eps = data.get('us_income_statement:eps_diluted')

# Mixing frequencies works fine; FinLab auto forward-fills
conditions = {
    'c1': close > close.average(20),
    'c2': revenue.average(2) > revenue.average(4),
    'c3': eps > 0,
}
Why do some combinations fail to backtest?

Possible causes:

  1. Conditions too strict: no stocks meet the intersection criteria
  2. Missing data: some conditions have insufficient data coverage
  3. Insufficient memory: too many combinations cause OOM

Check the log for specific error messages.

Next steps

↑↓ Move Enter Open Esc Close