Docs
Open Studio

Workflows

Multi-Strategy Portfolios

On this page12

This page combines several strategies of different styles into one portfolio: backtest the combination, adjust the weights, compute actual holdings with PortfolioSyncManager, then sync positions and place orders every day.

By the end you will have a Portfolio that combines three strategies, its backtest report, holdings sized to your capital, and a script that updates positions daily.

Before you start, you need:

  • Two or more backtested strategies. If you have none, step 1 builds three; for the full single-strategy workflow, see From research to live trading.
  • For live orders, a broker account connected through finlab.online; see Live trading.

Why combine strategies

Risk of a single strategyWhat a multi-strategy portfolio gives you
Market regime changes: a strategy may perform well in certain market conditions but fail when conditions changeRisk diversification: different strategies complement each other across different market environments
Drawdown risk: a single strategy's maximum drawdown can be very largeReduced drawdowns: portfolio maximum drawdown is typically smaller than any single strategy
Unstable returns: a strategy may underperform for extended periodsStable returns: smooths out individual strategy volatility

The whole workflow looks like this; if performance falls short, go back and adjust weights or strategies:

Yes

No

No

Yes

Yes

No

Build strategy pool

Multiple strategies?

Build a Portfolio

Backtest combined performance

Performance acceptable?

Adjust weights or strategies

Use PortfolioSyncManager

Compute share counts

Execute live trades

Periodic adjustment

Optimization needed?

1. Build three strategies of different styles

A recommended portfolio includes strategies of different styles. This one uses short-term technical momentum, medium-term fundamental value and medium-to-long-term revenue growth, all on S&P 500 stocks:

from finlab import data
from finlab.backtest import sim

data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')
    volume = data.get('price:volume')
    market_value = data.get('etl:us_market_value')
    net_income = data.get('us_income_statement:net_income')
    equity = data.get('us_balance_sheet:total_stockholders_equity')
    rev = data.get('us_income_statement:revenue')

# Strategy 1: Technical momentum strategy (short-term)

entry1 = (close == close.rolling(20).max()) & (volume > volume.average(20) * 1.5)
exit1 = close < close.average(10)

position1 = entry1.hold_until(exit=exit1, stop_loss=0.08)
report1 = sim(position1, resample='W', name="Momentum", upload=False)

# Strategy 2: Fundamental value strategy (medium-term)
pb = market_value / equity  # Price-to-book ratio
roe = net_income.rolling(4).sum() / equity  # Trailing-12-month ROE
pb_rank = pb[pb > 0].rank(axis=1, pct=True)  # 0 = cheapest, 1 = most expensive

entry2 = (pb_rank < 0.3) & (roe > 0.1)
exit2 = pb_rank > 0.7

position2 = entry2.hold_until(exit=exit2)
report2 = sim(position2, resample='M', name="Value", upload=False)

# Strategy 3: Revenue growth strategy (medium to long-term)
rev_growth = rev.pct_change(4)  # Year-over-year growth of quarterly revenue

entry3 = (rev_growth > 0.15) & (close > close.average(60))
exit3 = rev_growth < 0

position3 = entry3.hold_until(exit=exit3, stop_loss=0.1)
report3 = sim(position3, resample='M', name="Revenue Growth", upload=False)

print("Strategy pool built!")
print(f"Strategy 1 annualized return: {report1.get_stats()['cagr'] * 100:.2f}%")
print(f"Strategy 2 annualized return: {report2.get_stats()['cagr'] * 100:.2f}%")
print(f"Strategy 3 annualized return: {report3.get_stats()['cagr'] * 100:.2f}%")

If you have already uploaded the strategies to FinLab, load them from the cloud instead of rerunning them:

from finlab.portfolio import create_report_from_cloud

# Load previously uploaded strategies from the FinLab platform
report1 = create_report_from_cloud('My Momentum')
report2 = create_report_from_cloud('My Value')
report3 = create_report_from_cloud('My Revenue Growth')

print("Loaded 3 strategies from the cloud!")

2. Combine them with Portfolio

Portfolio takes a dictionary: each key is a strategy name and each value is (report, weight).

Equal weights

The simplest starting point, 1/3 each:

from finlab.portfolio import Portfolio

# Create equal-weight portfolio (1/3 each)
port_equal = Portfolio(
    {
        'Momentum': (report1, 1 / 3),
        'Value': (report2, 1 / 3),
        'Revenue Growth': (report3, 1 / 3),
    }
)

# Display portfolio performance
port_equal.display()

Weight by Sharpe ratio

A higher Sharpe ratio gets a higher weight. A negative Sharpe counts as 0: that strategy gets weight 0 and no capital:

# Get Sharpe ratio for each strategy (a negative Sharpe counts as 0: weight 0, no capital)
sharpe1 = max(report1.get_stats()['daily_sharpe'], 0)
sharpe2 = max(report2.get_stats()['daily_sharpe'], 0)
sharpe3 = max(report3.get_stats()['daily_sharpe'], 0)

# Calculate weights (higher Sharpe = higher weight)
total_sharpe = sharpe1 + sharpe2 + sharpe3
w1 = sharpe1 / total_sharpe
w2 = sharpe2 / total_sharpe
w3 = sharpe3 / total_sharpe

print(f"Momentum weight: {w1 * 100:.1f}%")
print(f"Value weight: {w2 * 100:.1f}%")
print(f"Revenue Growth weight: {w3 * 100:.1f}%")

# Create Sharpe-weighted portfolio
port_sharpe = Portfolio(
    {'Momentum': (report1, w1), 'Value': (report2, w2), 'Revenue Growth': (report3, w3)}
)

port_sharpe.display()

Compare the portfolios with each strategy

# Get portfolio performance
stats_equal = port_equal.get_stats()
stats_sharpe = port_sharpe.get_stats()

# Compare with individual strategies
import pandas as pd

comparison = pd.DataFrame(
    {
        'Momentum': [
            report1.get_stats()['cagr'],
            report1.get_stats()['daily_sharpe'],
            report1.get_stats()['max_drawdown'],
        ],
        'Value': [
            report2.get_stats()['cagr'],
            report2.get_stats()['daily_sharpe'],
            report2.get_stats()['max_drawdown'],
        ],
        'Revenue Growth': [
            report3.get_stats()['cagr'],
            report3.get_stats()['daily_sharpe'],
            report3.get_stats()['max_drawdown'],
        ],
        'Equal Weight': [
            stats_equal['cagr'],
            stats_equal['daily_sharpe'],
            stats_equal['max_drawdown'],
        ],
        'Sharpe-Weighted': [
            stats_sharpe['cagr'],
            stats_sharpe['daily_sharpe'],
            stats_sharpe['max_drawdown'],
        ],
    },
    index=['Annualized return', 'Sharpe ratio', 'Max drawdown'],
)

print(comparison)

When the strategies are weakly correlated, the portfolio columns show a higher Sharpe ratio and a smaller maximum drawdown than the individual strategies: that is the diversification effect.

3. Check the portfolio's risk and diversification

Run in-depth analysis on the portfolio

A Portfolio is itself a backtest report, so every analysis that works on a single strategy works on it. For how to read each one, see Strategy analysis modules.

# A Portfolio is itself a backtest report
combined_report = port_sharpe

# MAE/MFE analysis
combined_report.display_mae_mfe_analysis()

# Period stability
combined_report.run_analysis('PeriodStatsAnalysis')

# Alpha/Beta
combined_report.run_analysis('AlphaBetaAnalysis')

Check diversification and overlap

# Get portfolio positions
position_combined = combined_report.position

# Calculate average number of holdings
avg_holdings = (position_combined > 0).sum(axis=1).mean()
print(f"Average holdings count: {avg_holdings:.1f}")

# Calculate maximum single holding weight
max_weight = position_combined.max(axis=1).mean()
print(f"Average maximum single-holding weight: {max_weight * 100:.1f}%")

# Check overlap among three strategies
pos1 = report1.position.iloc[-1]
pos2 = report2.position.iloc[-1]
pos3 = report3.position.iloc[-1]

overlap_12 = len(set(pos1[pos1 > 0].index) & set(pos2[pos2 > 0].index))
overlap_13 = len(set(pos1[pos1 > 0].index) & set(pos3[pos3 > 0].index))
overlap_23 = len(set(pos2[pos2 > 0].index) & set(pos3[pos3 > 0].index))

print(f"Momentum & Value overlap count: {overlap_12}")
print(f"Momentum & Revenue Growth overlap count: {overlap_13}")
print(f"Value & Revenue Growth overlap count: {overlap_23}")

4. Adjust weights to recent conditions (optional)

Fixed weights already work. To let weights follow recent performance or the market regime, use one of these two approaches.

Minimum-variance weights from the past year

Use each strategy's daily returns over the past 252 trading days (about 1 year) to build a covariance matrix, then find the weights that minimize portfolio variance, with weights summing to 1 and each between 0 and 1. Requires scipy:

def optimize_weights(reports, lookback_days=252):
    """
    Dynamically adjust weights based on past year performance

    Args:
        reports: list of Report objects
        lookback_days: lookback period (default 252 trading days ~ 1 year)

    Returns:
        dict: optimal weights
    """
    import numpy as np
    from scipy.optimize import minimize

    # Get daily returns for the past year
    returns = []
    for report in reports:
        daily_return = report.daily_creturn.pct_change().iloc[-lookback_days:]
        returns.append(daily_return)

    returns_df = pd.concat(returns, axis=1).dropna()

    # Calculate covariance matrix
    cov_matrix = returns_df.cov()

    # Minimize variance (risk parity)
    def portfolio_variance(weights):
        return np.dot(weights, np.dot(cov_matrix, weights))

    # Constraints: weights sum to 1, each weight >= 0
    constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}
    bounds = tuple((0, 1) for _ in range(len(reports)))

    # Initial weights
    init_weights = np.array([1 / len(reports)] * len(reports))

    # Optimize
    result = minimize(
        portfolio_variance,
        init_weights,
        method='SLSQP',
        bounds=bounds,
        constraints=constraints,
    )

    return result.x

# Calculate optimal weights
optimal_weights = optimize_weights([report1, report2, report3])

print("Optimal weights:")
for i, w in enumerate(optimal_weights, 1):
    print(f"  Strategy {i}: {w * 100:.1f}%")

# Build optimally weighted portfolio
port_optimal = Portfolio(
    {
        'Momentum': (report1, optimal_weights[0]),
        'Value': (report2, optimal_weights[1]),
        'Revenue Growth': (report3, optimal_weights[2]),
    }
)

port_optimal.display()

Switch weights by market regime

Weight momentum more in a bull market, value more in a bear market, and balance them when sideways. The regime here comes from the S&P 500 and its 50-day and 200-day averages (a simplified example):

def adaptive_weights(reports, market_condition='bull'):
    """
    Adjust weights based on market conditions

    Args:
        reports: list of (name, Report) tuples
        market_condition: 'bull', 'bear', or 'sideways'

    Returns:
        dict: adjusted weights
    """
    if market_condition == 'bull':
        # Bull market: increase momentum strategy weight
        return [0.5, 0.25, 0.25]
    elif market_condition == 'bear':
        # Bear market: increase value strategy weight
        return [0.2, 0.5, 0.3]
    else:  # sideways
        # Sideways: balanced allocation
        return [0.33, 0.33, 0.34]

# Determine market condition from the S&P 500 (simplified example)
from finlab import data

sp500 = data.get('world_index:adj_close')['^GSPC'].dropna()
sp500_ma50 = sp500.rolling(50).mean()
sp500_ma200 = sp500.rolling(200).mean()

if sp500.iloc[-1] > sp500_ma50.iloc[-1] > sp500_ma200.iloc[-1]:
    market = 'bull'
    print("Market regime: bullish")
elif sp500.iloc[-1] < sp500_ma50.iloc[-1] < sp500_ma200.iloc[-1]:
    market = 'bear'
    print("Market regime: bearish")
else:
    market = 'sideways'
    print("Market regime: sideways")

# Adjust weights based on market conditions
adaptive_w = adaptive_weights([report1, report2, report3], market)

port_adaptive = Portfolio(
    {
        'Momentum': (report1, adaptive_w[0]),
        'Value': (report2, adaptive_w[1]),
        'Revenue Growth': (report3, adaptive_w[2]),
    }
)

5. Compute holdings and place orders

Create a PortfolioSyncManager

PortfolioSyncManager turns the portfolio and your capital into share counts per stock:

from finlab.portfolio import PortfolioSyncManager

# Create for the first time
pm = PortfolioSyncManager()

On later runs, continue from the saved holdings with PortfolioSyncManager.from_local() (or PortfolioSyncManager.from_cloud() if you saved them to FinLab cloud) instead of creating a new manager.

Update holdings for your capital

update only rebalances on rebalance dates:

# Set total capital
total_balance = 500_000  # $500,000

# Update holdings (rebalancing only happens on rebalance dates)
pm.update(port_sharpe, total_balance=total_balance)

# Display the combined target holdings: quantity and order condition per symbol
print(pm.get_position())
Odd lots and margin (Taiwan market only)

U.S. stocks already trade in single shares. For Taiwan portfolios, choose one call below to replace the pm.update() above. Keep the same odd_lot / margin_trading options on later calls, including the daily script in step 6.

These options apply only when a strategy is rebuilt: normally when rebalance is due, or when its weight changes with smooth_transition=False. Changing only these options after running the pm.update() above does not rebuild unchanged holdings; smooth_transition=False alone does not force a rebuild either.

# Odd lot trading (buy/sell fewer than 1,000 shares)
pm.update(port_sharpe, total_balance=total_balance, odd_lot=True)

# Margin trading (can use margin and short selling)
pm.update(port_sharpe, total_balance=total_balance, margin_trading=True)

# Combined
pm.update(port_sharpe, total_balance=total_balance, odd_lot=True, margin_trading=True)

Sync positions and place orders

pm.sync(account) syncs positions and places the orders directly:

from finlab.online.schwab_account import SchwabAccount

# Reads SCHWAB_API_KEY, SCHWAB_SECRET and SCHWAB_TOKEN_PATH from the environment
account = SchwabAccount()

# Use pm.sync to directly sync positions and place orders
pm.sync(account)

Taiwan brokers (SinoPac, Fubon, E.SUN, Taishin, formerly Masterlink) work the same way; see the multi-strategy order tutorial.

6. Sync every day

Put loading the strategies, updating positions and saving to the cloud into one function, and run it daily with cron or a scheduler, for example before the U.S. market opens (9:30 AM Eastern):

# Create a daily execution script
def daily_portfolio_sync():
    """
    Daily execution:
    1. Check if today is a rebalance date
    2. Check stop loss/take profit
    3. Update holdings
    """
    from finlab.exceptions import PortfolioError
    from finlab.portfolio import PortfolioSyncManager, create_report_from_cloud

    # Load strategies
    report1 = create_report_from_cloud('Momentum')
    report2 = create_report_from_cloud('Value')
    report3 = create_report_from_cloud('Revenue Growth')

    # Build portfolio
    port = Portfolio(
        {
            'Momentum': (report1, 0.4),
            'Value': (report2, 0.3),
            'Revenue Growth': (report3, 0.3),
        }
    )

    # Load existing positions
    pm = PortfolioSyncManager.from_cloud()

    # Update (actual rebalancing only on rebalance dates)
    try:
        pm.update(port, total_balance=500_000)
    except PortfolioError as error:
        print(f"Funding error; no stops or position updates were applied: {error}")
        # Raise total_balance and retry immediately before saving or syncing.
        raise

    # Sync to cloud
    pm.to_cloud()

    # To place orders, enable the following code:
    # account = SchwabAccount()
    # pm.sync(account)

    print("Daily sync complete!")

# Use cron or a scheduling tool for periodic execution
# e.g., run daily before the U.S. market opens (9:30 AM Eastern)

7. Track performance and reassess quarterly

Monitor live vs target

# Get target positions
target_position = pm.get_position()

# Display target holdings
print(target_position)

# Use pm.sync to simulate orders and check live vs target differences
pm.sync(account, view_only=True)

To rebuild NAV from your real fills and compare it with the strategy's targets day by day, see Real-trade review.

Reassess strategy weights each quarter

Drop strategies with a Sharpe ratio below 0.5 and reweight the rest by Sharpe ratio:

# Quarterly reassessment
def quarterly_rebalance():
    """
    Quarterly execution:
    1. Reassess each strategy's performance
    2. Adjust weights
    3. Remove underperforming strategies
    4. Add well-performing strategies
    """
    # Get the latest 3-month performance
    sharpe1 = report1.get_stats()['daily_sharpe']
    sharpe2 = report2.get_stats()['daily_sharpe']
    sharpe3 = report3.get_stats()['daily_sharpe']

    print(f"Momentum 3M Sharpe ratio: {sharpe1:.2f}")
    print(f"Value 3M Sharpe ratio: {sharpe2:.2f}")
    print(f"Revenue Growth 3M Sharpe ratio: {sharpe3:.2f}")

    # Remove strategies with Sharpe < 0.5
    active_strategies = {}
    if sharpe1 >= 0.5:
        active_strategies['Momentum'] = (report1, sharpe1)
    if sharpe2 >= 0.5:
        active_strategies['Value'] = (report2, sharpe2)
    if sharpe3 >= 0.5:
        active_strategies['Revenue Growth'] = (report3, sharpe3)

    # Reallocate weights
    total_sharpe = sum(s for _, s in active_strategies.values())
    new_port = Portfolio(
        {
            name: (report, sharpe / total_sharpe)
            for name, (report, sharpe) in active_strategies.items()
        }
    )

    print(f"\nKept {len(active_strategies)} strategies, reweighted")
    return new_port

# Execute quarterly
new_port = quarterly_rebalance()

Complete code

From the strategy pool to saving in the cloud, in one run:

# =============================================================================
# Multi-Strategy Portfolio Management Complete Example
# =============================================================================

from finlab import data
from finlab.backtest import sim
from finlab.portfolio import Portfolio, PortfolioSyncManager

# 1. Build strategy pool (S&P 500 stocks)
data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')
    volume = data.get('price:volume')
    market_value = data.get('etl:us_market_value')
    net_income = data.get('us_income_statement:net_income')
    equity = data.get('us_balance_sheet:total_stockholders_equity')
    rev = data.get('us_income_statement:revenue')

pb = market_value / equity
roe = net_income.rolling(4).sum() / equity
pb_rank = pb[pb > 0].rank(axis=1, pct=True)

# Strategy 1: Momentum
position1 = (
    (close == close.rolling(20).max()) & (volume > volume.average(20) * 1.5)
).hold_until(exit=close < close.average(10), stop_loss=0.08)
report1 = sim(position1, resample='W', name="Momentum", upload=False)

# Strategy 2: Value
position2 = ((pb_rank < 0.3) & (roe > 0.1)).hold_until(exit=pb_rank > 0.7)
report2 = sim(position2, resample='M', name="Value", upload=False)

# Strategy 3: Revenue Growth
rev_growth = rev.pct_change(4)
position3 = ((rev_growth > 0.15) & (close > close.average(60))).hold_until(
    exit=rev_growth < 0, stop_loss=0.1
)
report3 = sim(position3, resample='M', name="Revenue Growth", upload=False)

# 2. Build portfolio
port = Portfolio(
    {
        'Momentum': (report1, 0.4),
        'Value': (report2, 0.3),
        'Revenue Growth': (report3, 0.3),
    }
)

# 3. Evaluate portfolio performance
port.display()
print(port.get_stats())

# 4. Live execution
pm = PortfolioSyncManager()
pm.update(port, total_balance=500_000)
print(pm.get_position())

# 5. Sync to cloud
pm.to_cloud()

print("Multi-strategy portfolio built!")

Checklist

StageCheck
Strategy poolSelect strategies with different styles (technical, fundamental, institutional); low correlation between strategies for better diversification; each strategy must pass thorough backtest validation
WeightsEqual weight is the simplest starting point; Sharpe ratio weighting can improve overall risk-adjusted returns; reassess weights periodically (quarterly)
Backtest validationPortfolio Sharpe ratio should be higher than individual strategies; portfolio max drawdown should be smaller than individual strategies; check holding diversification and strategy overlap
Live executionUse PortfolioSyncManager for automated position management; sync regularly (daily) to ensure position consistency; monitor live vs backtest divergence
Continuous optimizationReassess strategy performance quarterly; remove strategies with Sharpe < 0.5; dynamically adjust weights based on market conditions

Frequently asked questions

How many strategies should a portfolio include?

3-5 strategies is recommended. Too few cannot effectively diversify, and too many dilute returns while increasing management complexity.

How do I tell whether a strategy should be removed?

Monitor the 3-6 month rolling Sharpe ratio; consider removing it if it consistently stays below 0.5.

Can I add or remove strategies over time?

Yes. Use quarterly_rebalance() from step 7 for periodic evaluation, adding well-performing strategies and removing underperformers.

What happens when several strategies hold the same stock?

PortfolioSyncManager sums the weights automatically. For example, if Strategy A holds AAPL at 5% and Strategy B holds AAPL at 3%, the final portfolio holds 8%.

How do I avoid excessive trading?
  • Use longer resample periods (M or Q)
  • Set minimum rebalancing thresholds (e.g., don't adjust if weight change < 2%)
  • Prefer strategies with lower rebalancing frequency

Next steps

↑↓ Move Enter Open Esc Close