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