Background
In an attempt to learn a bit more about investing and economics, I've begun writing a simple historical portfolio analysis tool in Python
.
The Portfolio
class is designed to facilitate portfolio analysis by fetching historical market data for a given list of assets and set of asset weightings within a specified date range. It calculates various portfolio metrics such as returns, volatility, and risk measures including P&L, beta, Sharpe ratio, and Conditional Value at Risk (CVaR). Additionally, it offers functionality to decompose asset volatility contributions and provides insights into portfolio performance over time.
I intend for the portfolio to automatically rebalance, and (think) I have achieved that effect by simply pulling data with the same frequency as the sought rebalancing frequency, and multiplying the monthly market returns (in %) by the weightings. Regrettably, I'm unable to compare this to readily available portfolio analysis tools, as they incorporate a variety of additional effects that I have not given consideration to. I'm unsure as to whether this accomplishes the desired effect, as I'm still not entirely comfortable with all of the jargon.
Code
import yfinance as yf
import pandas as pd
import numpy as np
from dataclasses import dataclass
@dataclass
class Portfolio:
tickers: list
weights: list
start_date: str
end_date: str
rebalancing_frequency: str = '1mo'
def __post_init__(self):
if len(self.tickers) != len(self.weights):
raise ValueError("The number of tickers must match the number of weights")
self.weights = np.array(self.weights) / np.sum(self.weights) # Normalize weights
self.market_data = self.get_market_data()
self.market_returns = self.calculate_market_returns()
def get_market_data(self):
try:
data = yf.download(self.tickers, start=self.start_date, end=self.end_date,
interval=self.rebalancing_frequency, progress=False)['Adj Close']
return data
except Exception as e:
print(f"Error fetching market data: {e}")
return pd.DataFrame()
def calculate_market_returns(self):
returns = self.market_data.pct_change().dropna()
return returns
def asset_volatility_decomposition(self):
asset_volatilities = self.market_returns.std(axis=0)
asset_volatility_decomposition = asset_volatilities * self.weights
return asset_volatility_decomposition
def portfolio_return_metrics(self):
portfolio_returns = self.market_returns @ self.weights
portfolio_value = (1 + portfolio_returns).cumprod()
cumulative_pnl = portfolio_value - 1
pnl = portfolio_value.diff().fillna(0)
return portfolio_returns, portfolio_value, cumulative_pnl, pnl
def portfolio_volatility_metrics(self, risk_free_rate=0.0, alpha=0.05):
portfolio_returns, _, _, _ = self.portfolio_return_metrics()
market_returns = self.market_returns.mean(axis=1)
portfolio_beta = np.cov(portfolio_returns, market_returns)[0, 1] / np.var(market_returns)
portfolio_cvar = portfolio_returns.quantile(alpha)
portfolio_annualized_std = portfolio_returns.std() * np.sqrt(12) * 100
annualized_sharpe_ratio = (portfolio_returns.mean() - risk_free_rate) / portfolio_std * np.sqrt(12)
downside_returns = portfolio_returns[portfolio_returns < risk_free_rate]
downside_std = downside_returns.std() if not downside_returns.empty else np.nan
sortino_ratio = (portfolio_returns.mean() - risk_free_rate) / downside_std if not np.isnan(downside_std) else np.nan
return portfolio_annualized_std, portfolio_beta, annualized_sharpe_ratio, portfolio_cvar, sortino_ratio
Example Usage
portfolio = Portfolio(
tickers=['APPL', 'MSFT'],
weights=[0.6, 0.4],
start_date='2020-01-01',
end_date='2024-01-01'
)
returns, value, cumulative_pnl, pnl = portfolio.portfolio_return_metrics()
portfolio_annualized_std, portfolio_beta, sharpe_ratio, cvar, sortino_ratio = portfolio.portfolio_volatility_metrics()
Goals
I seek to....
- Improve readability & conciseness.
- Identify any missed edge cases, obvious bugs, etc.
- Improve error handling, where possible/necessary.
tickers: list
is not enough: it could be a list of anything. I assumelist[str]
, with weights aslist[float]
? \$\endgroup\$