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 strategy | What a multi-strategy portfolio gives you |
|---|---|
| Market regime changes: a strategy may perform well in certain market conditions but fail when conditions change | Risk diversification: different strategies complement each other across different market environments |
| Drawdown risk: a single strategy's maximum drawdown can be very large | Reduced drawdowns: portfolio maximum drawdown is typically smaller than any single strategy |
| Unstable returns: a strategy may underperform for extended periods | Stable returns: smooths out individual strategy volatility |
The whole workflow looks like this; if performance falls short, go back and adjust weights or strategies:
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
| Stage | Check |
|---|---|
| Strategy pool | Select strategies with different styles (technical, fundamental, institutional); low correlation between strategies for better diversification; each strategy must pass thorough backtest validation |
| Weights | Equal weight is the simplest starting point; Sharpe ratio weighting can improve overall risk-adjusted returns; reassess weights periodically (quarterly) |
| Backtest validation | 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 | Use PortfolioSyncManager for automated position management; sync regularly (daily) to ensure position consistency; monitor live vs backtest divergence |
| Continuous optimization | Reassess 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
- Multi-strategy orders Order details for portfolios, weight allocation and broker setup.
- Real-trade review Rebuild NAV from your real fills and compare it with the targets day by day.
- Risk management Position caps, stops and drawdown control.
- finlab.portfolio API Full parameters for
PortfolioandPortfolioSyncManager.
Was this page helpful?