finlab.portfolio
Multi-strategy portfolio management module for combining multiple strategies into one portfolio, sizing its live holdings and syncing them to a broker account.
Use Cases
- Risk diversification: Combine different strategy types (momentum + value + growth) to reduce the risk of a single strategy failing
- Improved stability: Single strategies can be volatile; multi-strategy combinations effectively reduce maximum drawdown
- Dynamic weight adjustment: Flexibly adjust strategy proportions based on market conditions or strategy performance
- Live synchronization: Use PortfolioSyncManager to turn the combined targets into share quantities and sync a broker account
Portfolio vs Single Strategy Backtest
| Feature | Single Strategy (sim) | Portfolio |
|---|---|---|
| Use case | Individual strategy research | Multi-strategy combination management |
| Input | position DataFrame | Multiple Report objects |
| Weight allocation | Automatic equal weight | Customizable per-strategy weights |
| Risk control | Single strategy level | Portfolio-level rebalancing |
| Live sync | Position.from_report() + OrderExecutor |
PortfolioSyncManager |
| Complexity | Low | Medium |
Quick Examples
Basic Usage: Combine Three Strategies
from finlab import data
from finlab.backtest import sim
from finlab.portfolio import Portfolio
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
market_value = data.get('etl:us_market_value')
equity = data.get('us_balance_sheet:total_stockholders_equity')
revenue = data.get('us_income_statement:revenue')
# Strategy 1: Momentum strategy (MA breakout)
ma20 = close.average(20)
strength = close / ma20
position1 = strength[strength > 1].is_largest(
30
) # 30 stocks furthest above their 20-day average
report1 = sim(position1, resample='M', upload=False)
# Strategy 2: Value strategy (low P/B ratio)
pb = market_value / equity
position2 = pb[pb > 0].is_smallest(30) # 30 lowest positive P/B
report2 = sim(position2, resample='M', upload=False)
# Strategy 3: Revenue growth strategy (quarterly revenue, year over year)
rev_yoy = revenue.pct_change(4)
position3 = (rev_yoy > 0.2) & (
close > close.average(60)
) # aligned to daily filing dates
report3 = sim(position3, resample='M', upload=False)
# Build portfolio (weights: 40%, 30%, 30%)
portfolio = Portfolio(
{
'Momentum': (report1, 0.4),
'Value': (report2, 0.3),
'Revenue Growth': (report3, 0.3),
}
)
# Portfolio itself is a Report, use it directly
portfolio.display()
# View portfolio statistics
stats = portfolio.get_stats()
print(f"Portfolio annual return: {stats['cagr']:.2%}")
print(f"Portfolio Sharpe ratio: {stats['daily_sharpe']:.2f}")
print(f"Portfolio max drawdown: {stats['max_drawdown']:.2%}")
Detailed Guide
See Multi-Strategy Portfolio Management Workflow for:
- Strategy weight optimization methods (equal weight, risk parity, minimum volatility)
- Dynamic weight adjustment strategies (based on recent performance)
- Complete live synchronization workflow (PortfolioSyncManager)
- Tips for avoiding high strategy correlation
API Reference
Portfolio
Combine several backtest reports into one portfolio. Portfolio is itself a Report, so display(), get_stats(), creturn and the other report tools work on the combined result.
| Parameter | Type | Description |
|---|---|---|
reports |
dict[str, tuple[Report, float]] or Report |
Strategy name to (report, weight). A single Report is treated as one strategy with weight 1.0. Strategies with weight 0 are ignored; at least one weight must be nonzero. |
Portfolio.position uses the union of the strategies' rebalance dates, carrying each strategy's latest target position forward and keeping its allocation in cash before its first target.
Portfolio.creturn covers only dates with returns from every nonzero-weight strategy, so its period may be shorter than the position history. Daily returns are the weighted sum of the strategies' returns; weights are not rescaled, so a total below 1 leaves the rest in cash.
| Method | Description |
|---|---|
Portfolio.from_weight_function(reports, weight_function) |
Build a portfolio from {name: report} and a function returning a DataFrame of weights over time (one column per report name). Returns are computed with the weight history; the latest row sets the current weights. |
is_stop_triggered() |
True if any strategy has a pending stop-loss or take-profit exit. |
Weight Allocation Recommendations
Equal weight (suitable when strategies have similar characteristics):
# 3 strategies, 1/3 each
portfolio = Portfolio(
{
'Strategy A': (report1, 1 / 3),
'Strategy B': (report2, 1 / 3),
'Strategy C': (report3, 1 / 3),
}
)
Risk parity (inversely weighted by strategy volatility):
# Compute volatility for each strategy
vol1 = report1.creturn.pct_change().std()
vol2 = report2.creturn.pct_change().std()
vol3 = report3.creturn.pct_change().std()
# Inverse weights (lower volatility gets higher weight)
total_inv_vol = 1 / vol1 + 1 / vol2 + 1 / vol3
w1, w2, w3 = (
(1 / vol1) / total_inv_vol,
(1 / vol2) / total_inv_vol,
(1 / vol3) / total_inv_vol,
)
portfolio = Portfolio(
{
'Strategy A': (report1, w1),
'Strategy B': (report2, w2),
'Strategy C': (report3, w3),
}
)
Target volatility (set portfolio target volatility):
# First build equal-weight portfolio
portfolio = Portfolio({'Strategy A': (report1, 0.5), 'Strategy B': (report2, 0.5)})
# Compute portfolio volatility (Portfolio itself is a Report)
port_vol = portfolio.creturn.pct_change().std()
# Adjust weights to achieve target volatility (e.g., 15%)
target_vol = 0.15
leverage = target_vol / port_vol # Leverage ratio
# Reconfigure (ensure total weight <= 1)
if leverage <= 1:
portfolio = Portfolio(
{
'Strategy A': (report1, 0.5 * leverage),
'Strategy B': (report2, 0.5 * leverage),
}
)
Common Errors
1. Weights do not sum to 1
# Total weight 0.9: the remaining 10% stays in cash and earns nothing
portfolio = Portfolio(
{
'Strategy A': (report1, 0.4),
'Strategy B': (report2, 0.5),
}
)
# Fully invested: weights sum to 1
portfolio = Portfolio(
{
'Strategy A': (report1, 0.4),
'Strategy B': (report2, 0.6),
}
)
2. Mixing markets in one strategy
Each report should come from one market (data.set_market('us') for stocks, 'us_fund' for ETFs). Combine a stock report and an ETF report in a Portfolio instead of mixing their tickers in one sim() call.
create_multi_asset_report()
Backtest a fixed-weight basket and return its Report, for example to hold a set of stocks or ETFs and rebalance them periodically.
| Parameter | Type | Description |
|---|---|---|
stock_list |
dict[str, float] |
Symbol to weight, for example {'AAPL': 0.5, 'MSFT': 0.5}. |
**kwargs |
Passed to backtest.sim(). resample defaults to 'Q' (quarterly rebalancing); upload is always False. |
from finlab import data
from finlab.portfolio import create_multi_asset_report
with data.market('us_fund'): # ETFs trade in the U.S. fund market
report = create_multi_asset_report({'SPY': 0.6, 'QQQ': 0.4}, resample='M')
create_report_from_cloud()
Load a strategy report saved in FinLab's cloud (for example a strategy listed under Strategies in FinLab Studio) and return it as a CloudReport, which can be used like any Report, including in a Portfolio.
| Parameter | Type | Description |
|---|---|---|
strategy_id |
str |
Name of the cloud strategy. |
user_id |
str, optional |
Owner of the strategy; defaults to the logged-in user. |
market |
Market, optional |
Market of the strategy; detected from the saved report when omitted. |
PortfolioSyncManager
Keeps the live target holdings of a Portfolio: on each strategy's rebalance date it turns the strategy's weights into share quantities within the capital you provide, applies stop-loss and take-profit exits between rebalances, saves its state locally or in FinLab's cloud, and syncs a broker account to the result. Usually created empty or loaded with from_local() / from_cloud().
| Method | Description |
|---|---|
PortfolioSyncManager.from_local(name='default') / to_local(name='default') |
Load or save the state in FinLab's local workspace directory. |
PortfolioSyncManager.from_cloud(name='default') / to_cloud(name='default') |
Load or save the state in FinLab's cloud (requires login). |
PortfolioSyncManager.from_path(path) / to_path(path) |
Load or save the state at a file path. |
update(portfolio, total_balance=0, rebalance_safety_weight=0.2, smooth_transition=None, force_override_difference=False, custom_position=None, excluded_stock_ids=None, excluded_symbols=None, **kwargs) |
Recalculate the target holdings. Run it every day; see the parameter guide in the multi-strategy order tutorial. |
get_position(at='close', market_name=None) |
Combined target Position of all strategies (optionally of one market). |
get_strategy_position(strategy_name, at) |
Target holdings of one strategy. |
get_total_position() |
Taiwan market: holdings table with prices, weights and the strategies holding each stock. |
sync(account, at='close', consider_margin_as_asset=True, market_name=None, **kwargs) |
Sync the broker account to the target and return the OrderExecutor; **kwargs go to OrderExecutor.create_orders() (view_only, market_order, extra_bid_pct, ...). |
create_order_executor(account, at='close', consider_margin_as_asset=False, market_name=None) |
Build the OrderExecutor without placing orders. |
clear() |
Remove all saved holdings. |
update() parameters
| Parameter | Type | Description |
|---|---|---|
portfolio |
Portfolio or Report |
Strategies to deploy. |
total_balance |
float |
Total capital managed by this manager, supplied by you (it is never read from the broker); pass account.get_total_balance() to let it follow the account. |
rebalance_safety_weight |
float |
Share of the freed capital kept in cash on a rebalance (default 0.2). |
smooth_transition |
bool, optional |
Apply added, removed or reweighted strategies only on each strategy's next rebalance date; None decides automatically. |
force_override_difference |
bool |
Overwrite holdings that differ from the strategy report even on a non-rebalance day. |
custom_position |
Position or dict, optional |
Extra holdings to keep, excluded from the calculation. |
excluded_stock_ids / excluded_symbols |
list[str], optional |
Symbols never to hold. |
update() raises finlab.exceptions.PortfolioError without changing anything when it has to rebuild a strategy but total_balance leaves no capital after the holdings of the strategies it is not updating; raise total_balance and run it again.
Live Synchronization Best Practices
Daily routine (run after the close, before the next open):
from finlab.online.schwab_account import SchwabAccount
from finlab.portfolio import Portfolio, PortfolioSyncManager
account = SchwabAccount()
portfolio = Portfolio({'Momentum': (report1, 0.5), 'Value': (report2, 0.5)})
pm = PortfolioSyncManager.from_local()
pm.update(portfolio, total_balance=account.get_total_balance())
pm.to_local()
print(pm.get_position()) # target share quantities
pm.sync(account, view_only=True) # preview; drop view_only to place orders
The first run starts from PortfolioSyncManager() and pm.to_local() instead of from_local().
Synchronization Notes
- One manager per account:
sync()treats the whole account as this manager's holdings, sells positions that are not in the target and cancels open orders first. Keep manually managed holdings in another account. - Overlapping holdings are combined: if two strategies hold the same stock, its target is the weighted sum.
- Save after every update: call
to_local()orto_cloud()so the next run starts from the latest state.
FAQ
Q: How are position conflicts between strategies handled?
Portfolio automatically handles position conflicts:
# Suppose two strategies both hold AAPL
# Strategy A (weight 40%): AAPL at 20%
# Strategy B (weight 60%): AAPL at 15%
portfolio = Portfolio({'Strategy A': (report1, 0.4), 'Strategy B': (report2, 0.6)})
# Combined AAPL total weight = 0.4 * 0.20 + 0.6 * 0.15 = 0.17 (17%)
Q: How do I set a maximum number of holdings?
Use is_largest() in each strategy's backtest to limit holdings:
# Strategy 1: Max 30 stocks
position1 = (close > ma20).is_largest(30)
report1 = sim(position1, resample='M')
# Strategy 2: Max 20 stocks
position2 = pb[pb > 0].is_smallest(20)
report2 = sim(position2, resample='M')
# Combined: approximately 50 stocks max (may overlap)
portfolio = Portfolio({'Strategy 1': (report1, 0.5), 'Strategy 2': (report2, 0.5)})
Q: How do I dynamically adjust strategy weights?
# Method 1: Adjust based on recent performance
def dynamic_weights(reports, window=60):
"""Adjust weights based on last 60 days of performance"""
recent_returns = []
for report in reports:
ret = report.creturn.pct_change().tail(window).mean() # Recent average return
recent_returns.append(max(ret, 0)) # Set negative returns to 0
# Normalize
total = sum(recent_returns)
if total == 0:
return [1 / len(reports)] * len(reports) # Equal weight
return [r / total for r in recent_returns]
# Use dynamic weights
weights = dynamic_weights([report1, report2, report3], window=90)
portfolio = Portfolio(
{
'Strategy 1': (report1, weights[0]),
'Strategy 2': (report2, weights[1]),
'Strategy 3': (report3, weights[2]),
}
)
Q: Combined performance is worse than individual strategies?
Possible causes and how to investigate:
-
High strategy correlation:
-
Poor weight allocation:
-
Inconsistent backtest periods:
Resources
- Multi-Strategy Portfolio Management Workflow - In-depth guide
- Complete Strategy Development Workflow - From research to live trading
- Risk Management Guide - Portfolio-level risk control
- Live Trading Tutorial (Multi-Strategy) - Portfolio live execution
- GitHub Source Code