Skip to content

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

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

create_multi_asset_report(stock_list, **kwargs) -> 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()

create_report_from_cloud(strategy_id, user_id=None, market=None)

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

PortfolioSyncManager(data=None, start_with_empty=False) -> 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

  1. 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.
  2. Overlapping holdings are combined: if two strategies hold the same stock, its target is the weighted sum.
    # - Strategy A (allocation 40%): holds AAPL at 20%
    # - Strategy B (allocation 60%): holds AAPL at 15%
    # -> Combined AAPL weight = 0.4 * 20% + 0.6 * 15% = 17%
    
  3. Save after every update: call to_local() or to_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:

  1. High strategy correlation:

    # Check strategy correlation
    returns1 = report1.creturn.pct_change()
    returns2 = report2.creturn.pct_change()
    correlation = returns1.corr(returns2)
    print(f"Correlation: {correlation:.2f}")  # > 0.8 indicates high correlation
    
    # Solution: Choose strategies with low correlation (< 0.5)
    

  2. Poor weight allocation:

    # Check Sharpe ratio of each strategy
    print(f"Strategy 1 Sharpe: {report1.get_stats()['daily_sharpe']:.2f}")
    print(f"Strategy 2 Sharpe: {report2.get_stats()['daily_sharpe']:.2f}")
    
    # Give higher weight to strategies with higher Sharpe ratio
    

  3. Inconsistent backtest periods:

    # Ensure all strategies use the same backtest period
    from finlab import data
    
    data.truncate_start = '2018-01-01'
    
    report1 = sim(position1, resample='M')
    report2 = sim(position2, resample='M')
    # Ensure both reports have the same start date
    

Resources