Skip to content

Multi-Strategy Portfolio Management Complete Workflow

This document introduces how to build, manage, and deploy multi-strategy investment portfolios, covering the complete workflow from strategy pool creation to live execution.

Why Multi-Strategy Portfolios?

Risks of a single strategy:

  • Market regime changes: A strategy may perform well in certain market conditions but fail when conditions change
  • Drawdown risk: A single strategy's maximum drawdown can be very large
  • Unstable returns: A strategy may underperform for extended periods

Advantages of multi-strategy portfolios:

  • Risk diversification: Different strategies complement each other across different market environments
  • Reduced drawdowns: Portfolio maximum drawdown is typically smaller than any single strategy
  • Stable returns: Smooths out individual strategy volatility

Workflow Overview

graph TB
    A[Build strategy pool] --> B{Multiple strategies?}
    B -->|Yes| C[Build a Portfolio]
    B -->|No| A
    C --> D[Backtest combined performance]
    D --> E{Performance acceptable?}
    E -->|No| F[Adjust weights or strategies]
    F --> C
    E -->|Yes| G[Use PortfolioSyncManager]
    G --> H[Compute share counts]
    H --> I[Execute live trades]
    I --> J[Periodic adjustment]
    J --> K{Optimization needed?}
    K -->|Yes| F
    K -->|No| I

Stage 1: Building the Strategy Pool

1.1 Develop Multiple Strategies of Different Types

A recommended portfolio includes strategies of different styles:

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}%")

1.2 Load Previously Developed Strategies from Cloud

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!")

Stage 2: Combining Strategies with the Portfolio Module

2.1 Basic Portfolio: Equal Weight

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()

2.2 Advanced Portfolio: Sharpe Ratio-Weighted

# Get Sharpe ratio for each strategy
sharpe1 = report1.get_stats()['daily_sharpe']
sharpe2 = report2.get_stats()['daily_sharpe']
sharpe3 = report3.get_stats()['daily_sharpe']

# 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()

2.3 Portfolio Performance Evaluation

# 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)

Portfolio Advantages

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.


Stage 3: Backtesting the Multi-Strategy Portfolio

3.1 In-Depth Portfolio Analysis

# 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')

3.2 Check Portfolio Holding Diversification

# 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}")

Stage 4: Dynamic Weight Adjustment

4.1 Rolling Window Weight Recalculation

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()

4.2 Dynamic Switching Based on Market Conditions

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]),
    }
)

Stage 5: Live Execution

5.1 Using PortfolioSyncManager

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.

5.2 Update Holdings

# 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())

5.3 Enable Odd Lot or Margin Trading (Taiwan Market Only)

U.S. stocks already trade in single shares. For Taiwan portfolios, choose one call below to replace the pm.update() in 5.2. Keep the same odd_lot / margin_trading options on later calls, including the scheduled script in 5.5. 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 5.2 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)

5.4 Live Order Execution

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, Fugle, Masterlink) work the same way; see the multi-strategy order tutorial.

5.5 Scheduled Execution Script

# 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)

Daily calls with a fixed budget still process stops and removals when no sleeve needs rebuilding. A funding error during a rebalance or catch-up leaves the entire update unapplied, including stops in other sleeves. Monitor scheduling errors, correct the budget and retry immediately; do not wait for the next run or catch the error and sync the old target.


Stage 6: Performance Tracking & Adjustment

6.1 Monitor Live vs Backtest Divergence

# 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)

6.2 Periodic Strategy Weight Reassessment

# 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 Summary

# =============================================================================
# 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!")

Key Takeaways

Strategy Pool Building Stage

  • Select strategies with different styles (technical, fundamental, institutional)
  • Low correlation between strategies for better diversification
  • Each strategy must pass thorough backtest validation

Portfolio Weighting Stage

  • Equal weight is the simplest starting point
  • Sharpe ratio weighting can improve overall risk-adjusted returns
  • Reassess weights periodically (quarterly)

Backtest Validation Stage

  • Portfolio Sharpe ratio should be higher than individual strategies
  • Portfolio max drawdown should be smaller than individual strategies
  • Check holding diversification and strategy overlap

Live Execution Stage

  • Use PortfolioSyncManager for automated position management
  • Sync regularly (daily) to ensure position consistency
  • Monitor live vs backtest divergence

Continuous Optimization Stage

  • Reassess strategy performance quarterly
  • Remove strategies with Sharpe < 0.5
  • Dynamically adjust weights based on market conditions

Frequently Asked Questions

Q1: 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.

Q2: How to determine if a strategy should be removed?

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

Q3: Can strategies be added or removed dynamically?

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

Q4: How does PortfolioSyncManager handle conflicts?

When multiple strategies hold the same stock, weights are automatically summed. For example, if Strategy A holds AAPL at 5% and Strategy B holds AAPL at 3%, the final portfolio holds 8%.

Q5: How to 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

Reference Resources