Portfolio Analysis

This tutorial demonstrates how to construct and analyze portfolios using openseries, including optimization techniques and performance attribution.

Setting Up the Data

Let’s start by downloading data for a diversified set of assets:

import yfinance as yf
from openseries import OpenTimeSeries, OpenFrame
from openseries import efficient_frontier, simulate_portfolios

# Define our universe of assets
tickers = {
     "^GSPC": "S&P 500",
     "EFA": "EAFE International",
     "EEM": "Emerging Markets",
     "AGG": "US Aggregate Bonds",
     "VNQ": "US REITs",
     "GLD": "Gold",
     "DBC": "Commodities"
}

# Download 5 years of data
series_list = []
for ticker, name in tickers.items():
     # This may fail if the ticker is invalid or data unavailable
     data = yf.Ticker(ticker).history(period="5y")
     series = OpenTimeSeries.from_df(
          dframe=data['Close']
     )
     series.set_new_label(lvl_zero=name)
     series_list.append(series)
     print(f"Loaded {name}: {series.length} observations")

# Create OpenFrame
assets = OpenFrame(constituents=series_list)
print(f"\nCreated frame with {assets.item_count} assets")
print(f"Common date range: {assets.first_idx} to {assets.last_idx}")

Asset Analysis

First, let’s analyze the individual assets:

# Get metrics for all assets
asset_metrics = assets.all_properties()
print("=== INDIVIDUAL ASSET METRICS ===")
print(asset_metrics)

# Key metrics comparison
returns = asset_metrics.loc['Geometric return']
volatilities = asset_metrics.loc['Volatility']
sharpe_ratios = asset_metrics.loc['Return vol ratio']
max_drawdowns = asset_metrics.loc['Max drawdown']

print("\n=== ASSET COMPARISON ===")
for asset in returns.index:
    print(f"{asset}:")
    print(f"  Annual Return: {returns[asset]:.2%}")
    print(f"  Volatility: {volatilities[asset]:.2%}")
    print(f"  Sharpe Ratio: {sharpe_ratios[asset]:.2f}")
    print(f"  Max Drawdown: {max_drawdowns[asset]:.2%}")

Correlation Analysis

Understanding correlations is crucial for portfolio construction:

# Calculate correlation matrix
correlation_matrix = assets.correl_matrix
print("\n=== CORRELATION MATRIX ===")
print(correlation_matrix.round(3))

# Identify highly correlated pairs
print("\n=== HIGHLY CORRELATED PAIRS (>0.7) ===")
for i in range(len(correlation_matrix.columns)):
     for j in range(i+1, len(correlation_matrix.columns)):
          corr = correlation_matrix.iloc[i, j]
          if abs(corr) > 0.7:
                asset1 = correlation_matrix.columns[i]
                asset2 = correlation_matrix.columns[j]
                print(f"{asset1} - {asset2}: {corr:.3f}")

# Average correlation with other assets
avg_correlations = correlation_matrix.mean()
print("\n=== AVERAGE CORRELATIONS ===")
for asset, avg_corr in avg_correlations.items():
     print(f"{asset}: {avg_corr:.3f}")

Simple Portfolio Construction

Let’s start with basic portfolio construction methods:

Equal Weight Portfolio

# Create equal-weighted portfolio using native weight_strat
portfolio_df = assets.make_portfolio(name="Equal Weight Portfolio", weight_strat="eq_weights")
equal_weight_portfolio = OpenTimeSeries.from_df(dframe=portfolio_df)

print(f"Equal Weight Portfolio Return: {equal_weight_portfolio.geo_ret:.2%}")
print(f"Equal Weight Portfolio Volatility: {equal_weight_portfolio.vol:.2%}")
print(f"Equal Weight Portfolio Sharpe: {equal_weight_portfolio.ret_vol_ratio:.2f}")

Custom Weight Portfolio

You can also specify custom weights for portfolio construction:

# Define custom weights (must sum to 1)
custom_weights = [0.50, 0.15, 0.10, 0.15, 0.05, 0.03, 0.02]

assets.weights = custom_weights
portfolio_df = assets.make_portfolio(name="Custom Weighted")
custom_portfolio = OpenTimeSeries.from_df(dframe=portfolio_df)

print(f"Custom Portfolio Return: {custom_portfolio.geo_ret:.2%}")
print(f"Custom Portfolio Volatility: {custom_portfolio.vol:.2%}")
print(f"Custom Portfolio Sharpe: {custom_portfolio.ret_vol_ratio:.2f}")

Risk Parity Portfolio

# Use native inverse volatility weighting (risk parity)
portfolio_df = assets.make_portfolio(name="Risk Parity", weight_strat="inv_vol")
risk_parity_portfolio = OpenTimeSeries.from_df(dframe=portfolio_df)

print(f"Risk Parity Portfolio Return: {risk_parity_portfolio.geo_ret:.2%}")
print(f"Risk Parity Portfolio Volatility: {risk_parity_portfolio.vol:.2%}")
print(f"Risk Parity Portfolio Sharpe: {risk_parity_portfolio.ret_vol_ratio:.2f}")

Advanced Weight Strategies

OpenSeries provides additional weight strategies beyond basic equal weighting and risk parity:

Maximum Diversification Strategy

The maximum diversification strategy optimizes the correlation structure to maximize portfolio diversification:

from openseries.owntypes import MaxDiversificationNaNError, MaxDiversificationNegativeWeightsError

# This may fail with MaxDiversificationNaNError or MaxDiversificationNegativeWeightsError
max_div_portfolio_df = assets.make_portfolio(
     name="Maximum Diversification",
     weight_strat="max_div"
)
max_div_portfolio = OpenTimeSeries.from_df(dframe=max_div_portfolio_df)

print(f"Max Diversification Return: {max_div_portfolio.geo_ret:.2%}")
print(f"Max Diversification Volatility: {max_div_portfolio.vol:.2%}")
print(f"Max Diversification Sharpe: {max_div_portfolio.ret_vol_ratio:.2f}")

Minimum Volatility Overweight Strategy

The minimum volatility overweight strategy overweights the least volatile asset:

# This may fail with various exceptions
min_vol_portfolio_df = assets.make_portfolio(
     name="Min Vol Overweight",
     weight_strat="min_vol_overweight"
)
min_vol_portfolio = OpenTimeSeries.from_df(dframe=min_vol_portfolio_df)

print(f"Min Vol Overweight Return: {min_vol_portfolio.geo_ret:.2%}")
print(f"Min Vol Overweight Volatility: {min_vol_portfolio.vol:.2%}")
print(f"Min Vol Overweight Sharpe: {min_vol_portfolio.ret_vol_ratio:.2f}")

Strategy Comparison with Error Handling

When comparing multiple strategies, it’s important to handle potential failures gracefully:

strategies = {
     'Equal Weight': 'eq_weights',
     'Risk Parity': 'inv_vol',
     'Max Diversification': 'max_div',
     'Min Vol Overweight': 'min_vol_overweight'
}

results = {}
for name, strategy in strategies.items():
     # This may fail with MaxDiversificationNaNError, MaxDiversificationNegativeWeightsError, or other exceptions
     portfolio_df = assets.make_portfolio(name=name, weight_strat=strategy)
     portfolio = OpenTimeSeries.from_df(dframe=portfolio_df)
     results[name] = {
          'Return': portfolio.geo_ret,
          'Volatility': portfolio.vol,
          'Sharpe': portfolio.ret_vol_ratio
     }

if results:
     print("\n=== STRATEGY COMPARISON ===")
     for strategy_name, metrics in results.items():
         print(f"\n{strategy_name}:")
         print(f"  Return: {metrics['return']*100:.2f}%")
         print(f"  Volatility: {metrics['volatility']*100:.2f}%")
         print(f"  Sharpe: {metrics['sharpe']:.2f}")
         print(f"  Max Drawdown: {metrics['max_drawdown']*100:.2f}%")

Portfolio Optimization

Now let’s use openseries’ optimization tools:

Efficient Frontier

# Calculate efficient frontier
# This may fail with various exceptions
frontier_df, simulated_df, optimal_portfolio = efficient_frontier(
     eframe=assets,
     num_ports=50,
     seed=42
)

print("Efficient frontier calculated successfully")
print(f"Number of frontier points: {len(frontier_df)}")
print(f"Number of simulated portfolios: {len(simulated_df)}")

# Find maximum Sharpe ratio portfolio
sharpe_ratios = frontier_df['ret'] / frontier_df['stdev']
max_sharpe_idx = sharpe_ratios.idxmax()

print(f"\n=== MAXIMUM SHARPE RATIO PORTFOLIO ===")
print(f"Expected Return: {frontier_df.iloc[max_sharpe_idx]['ret']:.2%}")
print(f"Volatility: {frontier_df.iloc[max_sharpe_idx]['stdev']:.2%}")
print(f"Sharpe Ratio: {sharpe_ratios.iloc[max_sharpe_idx]:.2f}")

# Get optimal weights
optimal_weights = optimal_portfolio[-len(assets.constituents):]
print("\nOptimal Weights:")
for i, weight in enumerate(optimal_weights):
     asset_name = assets.constituents[i].label
     print(f"  {asset_name}: {weight:.1%}")

Monte Carlo Portfolio Simulation

# Simulate random portfolios
# This may fail with various exceptions
simulation_results = simulate_portfolios(
     simframe=assets,
     num_ports=10000,
     seed=42
)

print(f"\nSimulated {len(simulation_results)} random portfolios")

# Find best performing portfolios
sim_sharpe_ratios = simulation_results['ret'] / simulation_results['stdev']

# Top 5 Sharpe ratios
sorted_indices = sorted(range(len(sim_sharpe_ratios)), key=lambda i: sim_sharpe_ratios.iloc[i], reverse=True)
top_indices = sorted_indices[:5]

print("\n=== TOP 5 SIMULATED PORTFOLIOS ===")
for i, idx in enumerate(top_indices, 1):
     print(f"\nRank {i}:")
     print(f"  Return: {simulation_results.iloc[idx]['ret']:.2%}")
     print(f"  Volatility: {simulation_results.iloc[idx]['stdev']:.2%}")
     print(f"  Sharpe: {sim_sharpe_ratios.iloc[idx]:.2f}")

Portfolio Comparison

Let’s compare all our portfolios:

# Add all portfolios to a comparison frame
portfolios = [equal_weight_portfolio, market_cap_portfolio, risk_parity_portfolio]

# Add individual assets for comparison
all_series = assets.constituents + portfolios
comparison_frame = OpenFrame(constituents=all_series)

# Get comprehensive metrics
portfolio_metrics = comparison_frame.all_properties()

# Focus on key metrics
key_metrics = portfolio_metrics.loc[['Geometric return', 'Volatility', 'Return vol ratio', 'Max drawdown']]
key_metrics.index = ['Annual Return', 'Volatility', 'Sharpe Ratio', 'Max Drawdown']

print("\n=== PORTFOLIO COMPARISON ===")
print((key_metrics * 100).round(2))  # Convert to percentages

Risk Attribution

Analyze the risk contribution of each asset:

# Calculate portfolio statistics using openseries methods
# Create equal weight portfolio
equal_weight_portfolio_df = assets.make_portfolio(name="Equal Weight", weight_strat="eq_weights")
equal_weight_portfolio = OpenTimeSeries.from_df(dframe=equal_weight_portfolio_df)

print("\n=== RISK ATTRIBUTION (Equal Weight Portfolio) ===")
print(f"Portfolio Volatility: {equal_weight_portfolio.vol:.4f}")
print(f"Portfolio Return: {equal_weight_portfolio.geo_ret:.4f}")

# Individual asset contributions can be analyzed using openseries properties
for i, series in enumerate(assets.constituents):
    weight = equal_weights[i]
    asset_vol = series.vol
    print(f"\n{series.label}:")
    print(f"  Weight: {weight:.4f}")
    print(f"  Individual Volatility: {asset_vol:.4f}")
    print(f"  Weighted Contribution: {weight * asset_vol:.4f}")

Performance Attribution

Analyze performance contribution over time:

# Calculate performance attribution using openseries
# Individual asset performance is available through openseries properties
print("\n=== PERFORMANCE ATTRIBUTION ===")
for i, series in enumerate(assets.constituents):
    weight = equal_weights[i]
    asset_return = series.geo_ret
    contribution = weight * asset_return
    print(f"{series.label}:")
    print(f"  Weight: {weight:.2%}")
    print(f"  Return: {asset_return:.2%}")
    print(f"  Contribution: {contribution:.2%}")

# Cumulative contribution
cumulative_contrib = (1 + weighted_returns).cumprod()

print("\n=== PERFORMANCE ATTRIBUTION ===")
print("Final cumulative contribution by asset:")
final_contrib = cumulative_contrib.iloc[-1]
for asset, contrib in final_contrib.items():
     print(f"  {asset}: {contrib:.3f}")

Rolling Portfolio Analysis

Analyze how portfolio characteristics change over time:

# Rolling correlation with market (S&P 500)
market_proxy = assets.constituents[0]  # Assuming first asset is S&P 500

# Create frame with portfolio and market
portfolio_vs_market = OpenFrame(constituents=[equal_weight_portfolio, market_proxy])

# Calculate rolling correlation
rolling_corr = portfolio_vs_market.rolling_corr(observations=252)  # 1-year rolling

print(f"\nRolling correlation calculated for {len(rolling_corr)} periods")
print(f"Average correlation: {rolling_corr.mean().iloc[0]:.3f}")
print(f"Correlation range: {rolling_corr.min().iloc[0]:.3f} to {rolling_corr.max().iloc[0]:.3f}")

# Rolling portfolio volatility
portfolio_rolling_vol = equal_weight_portfolio.rolling_vol(observations=252)

print(f"\nRolling volatility statistics:")
print(f"Average volatility: {portfolio_rolling_vol.mean().iloc[0]:.2%}")
print(f"Volatility range: {portfolio_rolling_vol.min().iloc[0]:.2%} to {portfolio_rolling_vol.max().iloc[0]:.2%}")

Rebalancing Analysis

Analyze the impact of rebalancing frequency using the realistic rebalanced_portfolio method:

# Compare different rebalancing frequencies using realistic simulation
frequencies = [1, 21, 63]  # Daily, monthly, quarterly
frequency_names = ["Daily", "Monthly", "Quarterly"]

rebalanced_portfolios = []

for freq, name in zip(frequencies, frequency_names):
     portfolio = assets.rebalanced_portfolio(
          name=f"{name} Rebalanced",
          frequency=freq,
          bal_weights=equal_weights
     )
     rebalanced_portfolios.append(portfolio.constituents[-1])

# Compare with theoretical portfolio
assets.weights = equal_weights
theoretical_portfolio_df = assets.make_portfolio(name="Theoretical")
theoretical_portfolio = OpenTimeSeries.from_df(dframe=theoretical_portfolio_df)

# Create comprehensive comparison
all_portfolios = [theoretical_portfolio] + rebalanced_portfolios
comparison_frame = OpenFrame(constituents=all_portfolios)
comparison_metrics = comparison_frame.all_properties()

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

for series in all_portfolios:
     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:>15} | {ret:6.2f}% | {vol:10.2f}% | {sharpe:6.2f} | {max_dd:6.2f}%")

# Analyze transaction costs
print(f"\n=== TRANSACTION COST ANALYSIS ===")
for freq, name in zip(frequencies, frequency_names):
     detailed_portfolio = assets.rebalanced_portfolio(
          name=f"{name} Detailed",
          frequency=freq,
          bal_weights=equal_weights,
          drop_extras=False  # Get detailed trading data
     )

     # Count rebalancing events
     rebalancing_days = 0
     for series in detailed_portfolio.constituents:
          if "buysell_qty" in series.label:
                # Count days with non-zero trading
                trading_days = (series.tsdf != 0).any(axis=1).sum()
                rebalancing_days = max(rebalancing_days, trading_days)

     print(f"{name:>15}: {rebalancing_days} rebalancing events")

Stress Testing

Test portfolio performance during market stress:

# Identify worst periods for the market (modifies original)
market_proxy.value_to_ret()
market_returns_df = market_proxy.tsdf

# Find worst 5% of days
worst_days_threshold = market_returns_df.quantile(0.05).iloc[0]
worst_days = market_returns_df[market_returns_df <= worst_days_threshold]

print(f"\n=== STRESS TEST RESULTS ===")
print(f"Market stress threshold: {worst_days_threshold:.2%}")
print(f"Number of stress days: {len(worst_days)}")

# Portfolio performance during stress (modifies original)
equal_weight_portfolio.value_to_ret()
portfolio_returns_df = equal_weight_portfolio.tsdf

# Align dates and calculate portfolio performance during market stress
stress_dates = worst_days.index
portfolio_stress_returns = portfolio_returns_df.loc[stress_dates]

print(f"Portfolio average return during stress: {portfolio_stress_returns.mean().iloc[0]:.2%}")
print(f"Portfolio worst day during stress: {portfolio_stress_returns.min().iloc[0]:.2%}")

Summary Report

Generate a comprehensive portfolio analysis report:

print("\n" + "="*60)
print("PORTFOLIO ANALYSIS SUMMARY REPORT")
print("="*60)

print(f"\nAnalysis Period: {assets.first_idx} to {assets.last_idx}")
print(f"Number of Assets: {assets.item_count}")
print(f"Asset Universe: {', '.join([s.label for s in assets.constituents])}")

print(f"\n--- EQUAL WEIGHT PORTFOLIO PERFORMANCE ---")
print(f"Total Return: {equal_weight_portfolio.value_ret:.2%}")
print(f"Annualized Return: {equal_weight_portfolio.geo_ret:.2%}")
print(f"Annualized Volatility: {equal_weight_portfolio.vol:.2%}")
print(f"Sharpe Ratio: {equal_weight_portfolio.ret_vol_ratio:.2f}")
print(f"Maximum Drawdown: {equal_weight_portfolio.max_drawdown:.2%}")
print(f"95% VaR (daily): {equal_weight_portfolio.var_down:.2%}")

print(f"\n--- PORTFOLIO CHARACTERISTICS ---")
avg_correlation = correlation_matrix.mean().mean()
print(f"Average Asset Correlation: {avg_correlation:.3f}")
print(f"Portfolio Diversification Benefit: {(asset_metrics.loc['Volatility'].mean() - equal_weight_portfolio.vol):.2%}")

# Export results
portfolio_metrics.to_excel("portfolio_analysis.xlsx")
correlation_matrix.to_excel("correlation_matrix.xlsx")

print(f"\nResults exported to Excel files")
print("Analysis complete!")

This tutorial provides a comprehensive framework for portfolio analysis using openseries. You can extend these techniques for more sophisticated portfolio management strategies.