Docs
Open Studio

API reference

finlab.portfolio

On this page8

finlab.portfolio combines several backtest reports into one portfolio, using the weights you give. The portfolio is itself a report, so display() and get_stats() work on it directly. For live trading, PortfolioSyncManager turns the portfolio into share quantities and syncs a broker account to them.

Which function to use

You want toUse
Combine strategies with fixed weightsPortfolio()
Let strategy weights change over timePortfolio.from_weight_function()
Backtest a fixed-weight basket of stocks or ETFscreate_multi_asset_report()
Load a strategy report saved in FinLab's cloudcreate_report_from_cloud()
Turn the portfolio into quantities and sync a broker accountPortfolioSyncManager
Check whether any strategy has a pending stop exitportfolio.is_stop_triggered()

When to use a portfolio

Combining different strategy types (momentum + value + growth) reduces the risk of a single strategy failing, and usually lowers the maximum drawdown. You can adjust the weights to market conditions or strategy performance, and for live trading PortfolioSyncManager turns the combined targets into share quantities and syncs a broker account.

FeatureSingle strategy (sim)Portfolio
Use caseIndividual strategy researchMulti-strategy combination management
Inputposition DataFrameMultiple Report objects
Weight allocationAutomatic equal weightCustomizable per-strategy weights
Risk controlSingle strategy levelPortfolio-level rebalancing
Live syncPosition.from_report() + OrderExecutorPortfolioSyncManager
ComplexityLowMedium

Combine strategies

Portfolio()

Combines 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.

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%}")
ParameterTypeDefaultDescription
reportsdict[str, tuple[Report, float]] or ReportrequiredStrategy 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.

Weights need not sum to 1. Daily returns are the weighted sum of the strategies' returns; weights are not rescaled, so a total below 1 leaves the rest in cash:

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

Use one market per report. 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.

How the combined properties are computed:

  • 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 starts at 1 on the first date every nonzero-weight strategy covers and ends on the earliest last date, so its period may be shorter than the position history. A strategy with no row on a date in between (its market was closed) earns 0 that day while the others still count.
  • Portfolio.trades positions are shares of the whole portfolio (the strategy's position times its weight), and capacity is computed from them.

Choose the weights

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 annualized volatility):

# First build equal-weight portfolio
portfolio = Portfolio({'Strategy A': (report1, 0.5), 'Strategy B': (report2, 0.5)})

# Compute annualized portfolio volatility (assuming 252 trading days per year)
port_vol = portfolio.creturn.pct_change().std() * 252**0.5

# Adjust weights to achieve target annualized 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),
        }
    )

Portfolio.from_weight_function()

Builds a portfolio from a weight history, so each strategy's weight can change over time.

import pandas as pd
from finlab.portfolio import Portfolio

def weights():
    # One column per strategy name; each row takes effect from its date
    return pd.DataFrame(
        {'Momentum': [0.5, 0.7], 'Value': [0.5, 0.3]},
        index=pd.to_datetime(['2020-01-02', '2023-01-03']),
    )

portfolio = Portfolio.from_weight_function(
    {'Momentum': report1, 'Value': report2}, weights
)
ParameterTypeDefaultDescription
reportsdict[str, Report]requiredStrategy name to backtest report. Every name must be a column of the weight table.
weight_functionfunction with no argumentsrequiredReturns a DataFrame of weights over time, one column per report name. The latest row must not contain NaN.

Returns are computed with the weight history: each row is rebalanced into at the close of its date (or of the next trading day if its date is not one), earns from the following trading day, and holds until the next row. The latest row sets the current weights.

portfolio.is_stop_triggered()

Returns True if any strategy has a pending stop-loss or take-profit exit.

if portfolio.is_stop_triggered():
    print('A strategy has a pending stop exit')

Backtest a fixed-weight basket

create_multi_asset_report()

Backtests a fixed-weight basket and returns its Report, for example to hold a set of stocks or ETFs and rebalance them periodically.

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')
ParameterTypeDefaultDescription
stock_listdict[str, float]requiredSymbol to weight, for example {'AAPL': 0.5, 'MSFT': 0.5}.
**kwargsPassed to backtest.sim(). resample defaults to 'Q' (quarterly rebalancing); upload is always False.

Load a strategy from the cloud

create_report_from_cloud()

Loads a strategy report saved in FinLab's cloud (for example a strategy listed under Strategies in FinLab Studio) and returns it as a CloudReport, which can be used like any Report, including in a Portfolio.

from finlab.portfolio import Portfolio, create_report_from_cloud

report_a = create_report_from_cloud('Strategy A')
report_b = create_report_from_cloud('Strategy B')
portfolio = Portfolio({'Strategy A': (report_a, 0.5), 'Strategy B': (report_b, 0.5)})
ParameterTypeDefaultDescription
strategy_idstrrequiredName of the cloud strategy.
user_idstrNoneOwner of the strategy; defaults to the logged-in user.
marketMarketNoneMarket of the strategy; detected from the saved report when omitted.

Sync live holdings

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

Run it once a day, after the close and 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(). The constructor's full signature is PortfolioSyncManager(data=None, start_with_empty=False).

Save and load the state

Load or save the manager's state in FinLab's local workspace directory, in FinLab's cloud (requires login), or at a file path.

pm = PortfolioSyncManager.from_cloud('default')
pm.to_cloud('default')
MethodDefaultDescription
PortfolioSyncManager.from_local(name) / to_local(name)name='default'FinLab's local workspace directory.
PortfolioSyncManager.from_cloud(name) / to_cloud(name)name='default'FinLab's cloud (requires login).
PortfolioSyncManager.from_path(path) / to_path(path)A file path you choose.

pm.update()

Recalculates the target holdings. Run it every day; see the parameter guide in the multi-strategy order tutorial.

pm.update(portfolio, total_balance=account.get_total_balance(), excluded_symbols=['TSLA'])
ParameterTypeDefaultDescription
portfolioPortfolio or ReportrequiredStrategies to deploy.
total_balancefloat0Total 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_weightfloat0.2Share of the freed capital kept in cash on a rebalance.
smooth_transitionboolNoneApply added, removed or reweighted strategies only on each strategy's next rebalance date; None decides automatically.
force_override_differenceboolFalseOverwrite holdings that differ from the strategy report even on a non-rebalance day.
custom_positionPosition or dictNoneExtra holdings to keep, excluded from the calculation.
excluded_stock_ids / excluded_symbolslist[str]NoneSymbols 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.

pm.get_position()

Returns the combined target Position of all strategies, or of one market. at defaults to 'close'.

pm.get_position()
ParameterTypeDefaultDescription
market_namestrNoneOnly this market; None returns all markets.

pm.get_strategy_position()

Returns the target holdings of one strategy.

pm.get_strategy_position('Momentum', at='close')
ParameterTypeDefaultDescription
strategy_namestrrequiredName of the strategy in the portfolio.
atstrrequiredAs in get_position(), but with no default here.

pm.get_total_position()

Taiwan market: returns a holdings table with prices, weights and the strategies holding each stock.

pm.get_total_position()

pm.sync()

Syncs the broker account to the target and returns the OrderExecutor.

pm.sync(account, view_only=True)  # preview
pm.sync(account, extra_bid_pct=0.01)  # place orders
ParameterTypeDefaultDescription
accountAccountrequiredA logged-in broker account.
atstr'close'As in get_position().
consider_margin_as_assetboolTrueCount margin positions as assets.
market_namestrNoneOnly this market; None syncs all markets.
**kwargsPassed to OrderExecutor.create_orders() (view_only, market_order, extra_bid_pct, ...).

pm.create_order_executor()

Builds the OrderExecutor without placing orders, so you can inspect or place them yourself.

executor = pm.create_order_executor(account)
executor.create_orders(view_only=True)
ParameterTypeDefaultDescription
accountAccountrequiredA logged-in broker account.
atstr'close'As in get_position().
consider_margin_as_assetboolFalseCount margin positions as assets.
market_namestrNoneOnly this market; None covers all markets.

pm.clear()

Removes all saved holdings.

pm.clear()

Common questions

What happens when two strategies hold the same stock?

Portfolio combines them automatically:

# 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%)
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)})
How do I weight strategies by recent performance?
# 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]),
    }
)

For weights that change during the backtest itself, use Portfolio.from_weight_function() above.

Why is the combined result worse than the 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

Next steps

Source code is on GitHub.

↑↓ Move Enter Open Esc Close