Rebalanced Portfolio Simulation

This example demonstrates how to use the rebalanced_portfolio method to simulate realistic portfolio trading strategies with periodic rebalancing, cash management, and transaction cost tracking.

Understanding Rebalanced Portfolio Simulation

The rebalanced_portfolio method simulates actual trading mechanics rather than theoretical portfolio calculations. It:

  • Simulates real trading: Executes buy/sell transactions on rebalancing days

  • Manages cash separately: Tracks cash position with interest accrual

  • Tracks transaction costs: Implicitly accounts for trading friction

  • Provides detailed data: Returns position-level information for analysis

Basic Rebalanced Portfolio Setup

Let’s start with a simple example using simulated data:

from openseries import OpenTimeSeries, OpenFrame, ValueType
from openseries.simulation import ReturnSimulation
import datetime as dt

# Create simulated asset data
 seed = 55
 end_date = dt.date(2023, 12, 31)

 # Generate DIFFERENT returns for each asset to create realistic drift
 assets = []
 asset_names = ["Equity Fund", "Bond Fund", "Commodity Fund"]

 # Create different simulations for each asset with different parameters
 asset_params = [
      {
            "mean_annual_return": 0.12,
            "mean_annual_vol": 0.20,
            "seed": seed,
      },  # Equity: higher return, higher vol
      {
            "mean_annual_return": 0.05,
            "mean_annual_vol": 0.08,
            "seed": seed + 1,
      },  # Bond: lower return, lower vol
      {
            "mean_annual_return": 0.08,
            "mean_annual_vol": 0.25,
            "seed": seed + 2,
      },  # Commodity: medium return, high vol
 ]

 for name, params in zip(asset_names, asset_params, strict=False):
      seriesim = ReturnSimulation.from_lognormal(
            number_of_sims=1,
            trading_days=2520,
            mean_annual_return=params["mean_annual_return"],
            mean_annual_vol=params["mean_annual_vol"],
            seed=params["seed"],
      )

      series = OpenTimeSeries.from_df(
            dframe=seriesim.to_dataframe(name=name, end=end_date),
            valuetype=ValueType.RTRN,
      ).to_cumret()
      series.set_new_label(lvl_zero=name)
      assets.append(series)

 # Create investment universe
 weights = [0.6, 0.3, 0.1]
 investment_universe = OpenFrame(constituents=assets, weights=weights)
 investment_universe.align_index_to_local_cdays(countries="SE")

 print(f"Created investment universe with {investment_universe.item_count} assets")
 print(
      f"Analysis period: {investment_universe.first_idx} to {investment_universe.last_idx}"
 )
 print(f"Target weights: {investment_universe.weights}")

Daily Rebalancing vs Theoretical Portfolio

Let’s compare daily rebalancing with the theoretical portfolio calculation:

# Create theoretical portfolio (no rebalancing friction)
theoretical_portfolio_df = investment_universe.make_portfolio(
    name="Theoretical Portfolio", weight_strat="eq_weights"
)
theoretical_portfolio = OpenTimeSeries.from_df(dframe=theoretical_portfolio_df)

# Create monthly rebalanced portfolio (simulates actual trading)
monthly_rebalanced = investment_universe.rebalanced_portfolio(
    name="Monthly Rebalanced",
    frequency=21,  # Roughly monthly
)

# Extract portfolio series for comparison
theoretical_series = theoretical_portfolio
monthly_rebalanced_series = monthly_rebalanced.constituents[-1]

print("=== PORTFOLIO COMPARISON ===")
print("Theoretical Portfolio:")
print(f"  Total Return: {theoretical_series.value_ret:.2%}")
print(f"  Annualized Return: {theoretical_series.geo_ret:.2%}")
print(f"  Volatility: {theoretical_series.vol:.2%}")
print(f"  Sharpe Ratio: {theoretical_series.ret_vol_ratio:.2f}")

print("\nmonthly Rebalanced Portfolio:")
print(f"  Total Return: {monthly_rebalanced_series.value_ret:.2%}")
print(f"  Annualized Return: {monthly_rebalanced_series.geo_ret:.2%}")
print(f"  Volatility: {monthly_rebalanced_series.vol:.2%}")
print(f"  Sharpe Ratio: {monthly_rebalanced_series.ret_vol_ratio:.2f}")

# Calculate difference
return_diff = monthly_rebalanced_series.geo_ret - theoretical_series.geo_ret
vol_diff = monthly_rebalanced_series.vol - theoretical_series.vol

print("\nDifference (Rebalanced - Theoretical):")
print(f"  Return Difference: {return_diff:+.2%}")
print(f"  Volatility Difference: {vol_diff:+.2%}")

Different Rebalancing Frequencies

Now let’s compare different rebalancing frequencies:

# Test different rebalancing frequencies
frequencies = [1, 5, 21, 63]  # Daily, weekly, monthly, quarterly
frequency_names = ["Daily", "Weekly", "Monthly", "Quarterly"]

portfolios = []

for freq, name in zip(frequencies, frequency_names):
     portfolio = investment_universe.rebalanced_portfolio(
          name=f"{name} Rebalanced",
          frequency=freq
     )
     portfolios.append(portfolio.constituents[-1])  # Get portfolio series

# Create comparison frame
comparison_frame = OpenFrame(constituents=portfolios)
metrics = comparison_frame.all_properties()

print("\n=== REBALANCING FREQUENCY COMPARISON ===")
print("Frequency | Return | Volatility | Sharpe | Max DD")
print("-" * 50)

for i, name in enumerate(frequency_names):
     ret = metrics.loc['Geometric return', portfolios[i].label].iloc[0] * 100
     vol = metrics.loc['Volatility', portfolios[i].label].iloc[0] * 100
     sharpe = metrics.loc['Return vol ratio', portfolios[i].label].iloc[0]
     max_dd = metrics.loc['Max drawdown', portfolios[i].label].iloc[0] * 100

     print(f"{name:>9} | {ret:6.2f}% | {vol:10.2f}% | {sharpe:6.2f} | {max_dd:6.2f}%")

Detailed Portfolio Analysis

Let’s examine the detailed trading data by setting drop_extras=False:

# Get detailed trading data
detailed_portfolio = investment_universe.rebalanced_portfolio(
     name="Detailed Analysis",
     frequency=21,  # Monthly rebalancing
     drop_extras=False  # Return all trading details
)

print(f"\nDetailed portfolio contains {detailed_portfolio.item_count} series")
print("Available data series:")
for series in detailed_portfolio.constituents:
     print(f"  - {series.label}")

# Extract key trading metrics
portfolio_twr = None
cash_position = None

for series in detailed_portfolio.constituents:
     if "Detailed Analysis, twr" in series.label:
          portfolio_twr = series
     elif "cash, twr" in series.label:
          cash_position = series

if portfolio_twr and cash_position:
     print(f"\n=== TRADING ANALYSIS ===")
     print(f"Portfolio TWR (final): {portfolio_twr.tsdf.iloc[-1, 0]:.4f}")
     print(f"Cash TWR (final): {cash_position.tsdf.iloc[-1, 0]:.4f}")

     # Calculate cash as percentage of portfolio
     cash_pct = cash_position.tsdf.iloc[-1, 0] / portfolio_twr.tsdf.iloc[-1, 0] * 100
     print(f"Cash as % of portfolio: {cash_pct:.2f}%")

Equal Weight vs Custom Weight Strategies

Compare equal weight strategy with custom weights:

# Equal weight strategy
equal_weight_portfolio = investment_universe.rebalanced_portfolio(
     name="Equal Weight Strategy",
     frequency=21,
     equal_weights=True  # Use equal weights
)

# Custom weight strategy
custom_weights = [0.7, 0.2, 0.1]  # 70% equity, 20% bonds, 10% commodities
custom_weight_portfolio = investment_universe.rebalanced_portfolio(
     name="Custom Weight Strategy",
     frequency=21,
     bal_weights=custom_weights
)

# Compare strategies
strategies = [
     equal_weight_portfolio.constituents[-1],
     custom_weight_portfolio.constituents[-1]
]

strategy_frame = OpenFrame(constituents=strategies)
strategy_metrics = strategy_frame.all_properties()

print("\n=== STRATEGY COMPARISON ===")
print("Strategy | Return | Volatility | Sharpe | Max DD")
print("-" * 50)

for strategy in strategies:
     ret = strategy_metrics.loc['Geometric return', strategy.label].iloc[0] * 100
     vol = strategy_metrics.loc['Volatility', strategy.label].iloc[0] * 100
     sharpe = strategy_metrics.loc['Return vol ratio', strategy.label].iloc[0]
     max_dd = strategy_metrics.loc['Max drawdown', strategy.label].iloc[0] * 100

     print(f"{strategy.label:>15} | {ret:6.2f}% | {vol:10.2f}% | {sharpe:6.2f} | {max_dd:6.2f}%")

Cash Management Analysis

Let’s examine how cash is managed in the rebalanced portfolio:

# Create portfolio with cash analysis
cash_analysis = investment_universe.rebalanced_portfolio(
     name="Cash Analysis",
     frequency=21,
     drop_extras=False
)

# Extract cash-related series
cash_series = {}
for series in cash_analysis.constituents:
     if "cash" in series.label.lower():
          series_type = series.label.split(", ")[1] if ", " in series.label else series.label
          cash_series[series_type] = series

print("\n=== CASH MANAGEMENT ANALYSIS ===")
print("Available cash data:")
for data_type, series in cash_series.items():
     print(f"  - {data_type}: {len(series.tsdf)} observations")

# Analyze cash position over time
if "position" in cash_series:
     cash_positions = cash_series["position"].tsdf
     print(f"\nCash position statistics:")
     print(f"  Average cash position: {cash_positions.mean().iloc[0]:.4f}")
     print(f"  Maximum cash position: {cash_positions.max().iloc[0]:.4f}")
     print(f"  Minimum cash position: {cash_positions.min().iloc[0]:.4f}")
     print(f"  Final cash position: {cash_positions.iloc[-1, 0]:.4f}")

Subset Portfolio Analysis

Analyze performance with a subset of assets:

# Create portfolio with only equity and bonds (exclude commodities)
subset_portfolio = investment_universe.rebalanced_portfolio(
     name="Equity-Bond Portfolio",
     items=["Equity Fund", "Bond Fund"],  # Only use these assets
     bal_weights=[0.7, 0.3],  # 70% equity, 30% bonds
     frequency=21
)

# Compare with full universe
full_portfolio = investment_universe.rebalanced_portfolio(
     name="Full Universe Portfolio",
     frequency=21
)

# Performance comparison
comparison_series = [
     subset_portfolio.constituents[-1],
     full_portfolio.constituents[-1]
]

comparison_frame = OpenFrame(constituents=comparison_series)
comparison_metrics = comparison_frame.all_properties()

print("\n=== SUBSET vs FULL UNIVERSE ===")
print("Portfolio | Return | Volatility | Sharpe | Max DD")
print("-" * 50)

for series in comparison_series:
     ret = comparison_metrics.loc['Geometric return', series.label].iloc[0] * 100
     vol = comparison_metrics.loc['Volatility', series.label].iloc[0] * 100
     sharpe = comparison_metrics.loc['Return vol ratio', series.label].iloc[0]
     max_dd = comparison_metrics.loc['Max drawdown', series.label].iloc[0] * 100

     print(f"{series.label:>20} | {ret:6.2f}% | {vol:10.2f}% | {sharpe:6.2f} | {max_dd:6.2f}%")

Transaction Cost Analysis

Analyze the implicit transaction costs from rebalancing:

# Get detailed transaction data
transaction_data = investment_universe.rebalanced_portfolio(
     name="Transaction Analysis",
     frequency=21,
     drop_extras=False
)

# Extract transaction-related series
transaction_series = {}
for series in transaction_data.constituents:
     if "buysell_qty" in series.label or "settle" in series.label:
          transaction_series[series.label] = series

print("\n=== TRANSACTION ANALYSIS ===")
print("Transaction data available:")
for name, series in transaction_series.items():
     print(f"  - {name}: {len(series.tsdf)} observations")

# Calculate total trading activity
total_trades = 0
for name, series in transaction_series.items():
     if "buysell_qty" in name:
          # Sum absolute trading quantities
          total_trades += series.tsdf.abs().sum().iloc[0]

print(f"\nTotal trading activity: {total_trades:.2f}")
print("(Sum of absolute buy/sell quantities across all assets)")

Performance Attribution

Analyze the contribution of each asset to portfolio performance:

# Get individual asset performance from rebalanced portfolio
asset_performance = investment_universe.rebalanced_portfolio(
     name="Asset Performance Analysis",
     frequency=21
)

print("\n=== ASSET PERFORMANCE ATTRIBUTION ===")
print("Asset | Final TWR | Contribution")
print("-" * 40)

# Calculate weighted contribution
target_weights = investment_universe.weights

for i, series in enumerate(asset_performance.constituents[:-1]):  # Exclude portfolio series
     final_twr = series.tsdf.iloc[-1, 0]
     weight = target_weights[i]
     contribution = final_twr * weight

     print(f"{series.label:>15} | {final_twr:8.4f} | {contribution:8.4f}")

# Portfolio total
portfolio_series = asset_performance.constituents[-1]
portfolio_twr = portfolio_series.tsdf.iloc[-1, 0]
print(f"{'Portfolio Total':>15} | {portfolio_twr:8.4f} | {portfolio_twr:8.4f}")

Real-World Application Example

Here’s a practical example using real market data:

import yfinance as yf

# Download real market data
tickers = ["SPY", "TLT", "GLD"]  # S&P 500, Long-term Treasury, Gold
names = ["S&P 500", "US Treasury", "Gold"]

real_assets = []
for ticker, name in zip(tickers, names):
     # This may fail if the ticker is invalid or data unavailable
     data = yf.Ticker(ticker).history(period="3y")
     series = OpenTimeSeries.from_df(
          dframe=data['Close'],
          name=name
     )
     real_assets.append(series)
     print(f"Loaded {name}: {series.length} observations")

if len(real_assets) >= 2:
     # Create real-world portfolio
     real_universe = OpenFrame(constituents=real_assets)
     real_universe.weights = [0.6, 0.3, 0.1]  # 60% stocks, 30% bonds, 10% gold

     # Monthly rebalanced portfolio
     real_portfolio = real_universe.rebalanced_portfolio(
          name="Real-World Portfolio",
          frequency=21,  # Approximately monthly
     )

     portfolio_series = real_portfolio.constituents[-1]

     print(f"\n=== REAL-WORLD PORTFOLIO RESULTS ===")
     print(f"Analysis period: {real_universe.first_idx} to {real_universe.last_idx}")
     print(f"Total return: {portfolio_series.value_ret:.2%}")
     print(f"Annualized return: {portfolio_series.geo_ret:.2%}")
     print(f"Volatility: {portfolio_series.vol:.2%}")
     print(f"Sharpe ratio: {portfolio_series.ret_vol_ratio:.2f}")
     print(f"Maximum drawdown: {portfolio_series.max_drawdown:.2%}")

Summary and Best Practices

Key takeaways for using rebalanced_portfolio:

  1. Realistic Simulation: Unlike make_portfolio, this method simulates actual trading with transaction costs and cash management.

  2. Rebalancing Frequency: Higher frequency (lower number) means more trading but closer to target weights. Consider transaction costs vs. tracking error.

  3. Cash Management: The method automatically handles cash positions and can include cash interest if a cash index is provided.

  4. Detailed Analysis: Use drop_extras=False to get comprehensive trading data for analysis.

  5. Performance Attribution: Individual asset series show the actual performance of each position in the portfolio.

  6. Transaction Costs: The method implicitly accounts for trading friction through settlement tracking.

This simulation approach provides a more realistic view of portfolio performance compared to theoretical calculations, making it valuable for backtesting and strategy evaluation.