Files
OpenBB/openbb_terminal/portfolio/attribution_model.py
montezdesousa 570e579067 Portfolio docstring example fixes and some bugs (#3431)
* fix doctest

* fix some type hints

* reorganize portfolio folders

* fix rfr

* fix vol bug

* fix more docs

* fix failing test

* doc typo

* numpy fix

* pydocstyle

* regenerate website docs
2022-11-18 19:30:55 +00:00

393 lines
12 KiB
Python

"""Attribution Model"""
__docformat__ = "numpy"
from datetime import datetime
from datetime import date
import logging
from typing import Dict
import yfinance as yf
import pandas as pd
from openbb_terminal.decorators import log_start_end
logger = logging.getLogger(__name__)
SPY_SECTORS_MAP = {
"S&P 500 Materials (Sector)": "basic_materials",
"S&P 500 Industrials (Sector)": "industrials",
"S&P 500 Consumer Discretionary (Sector)": "consumer_cyclical",
"S&P 500 Consumer Staples (Sector)": "consumer_defensive",
"S&P 500 Health Care (Sector)": "healthcare",
"S&P 500 Financials (Sector)": "financial_services",
"S&P 500 Information Technology (Sector)": "technology",
"S&P 500 Telecommunication Services (Sector)": "communication_services",
"S&P 500 Utilities (Sector)": "utilities",
"S&P 500 Real Estate (Sector)": "realestate",
"S&P 500 Energy (Sector)": "energy",
}
PF_SECTORS_MAP = {
"Basic Materials": "S&P 500 Materials (Sector)",
"Industrials": "S&P 500 Industrials (Sector)",
"Consumer Cyclical": "S&P 500 Consumer Discretionary (Sector)",
"Consumer Defensive": "S&P 500 Consumer Staples (Sector)",
"Healthcare": "S&P 500 Health Care (Sector)",
"Financial Services": "S&P 500 Financials (Sector)",
"Technology": "S&P 500 Information Technology (Sector)",
"Communication Services": "S&P 500 Telecommunication Services (Sector)",
"Utilities": "S&P 500 Utilities (Sector)",
"Real Estate": "S&P 500 Real Estate (Sector)",
"Energy": "S&P 500 Energy (Sector)",
}
@log_start_end(log=logger)
def get_spy_sector_contributions(
start_date, end_date=date.today()
) -> pd.DataFrame: # format like 2015-01-15 (YYYY-MM-DD)
"""
Fetch sector contributions for the SPY for a fixed period
Parameters
----------
start_date : str ('yyyy-mm-dd') or datetime.date
start date for fetching data
end_date : str ('yyyy-mm-dd') or datetime.date
end date for fetching data
Returns
-------
contributions : pd.DataFrame
DataFrame with contributions for each sector
"""
# Sector Map
sectors_ticker = "SPY"
# Load in info
sp500_tickers_data = get_daily_sector_prices(start_date, end_date)
weight_data = yf.Ticker(sectors_ticker).info["sectorWeightings"]
# reformat Data
weights: Dict[str, dict] = {"SPY": {}}
for sector in weight_data:
weights[sectors_ticker].update(sector)
# add the sectors + dates + adj close to the dataframe
records = []
for sector, data in sp500_tickers_data.items():
for x in range(0, len(data["sector_data"])):
record = {
"sector": sector,
"date": data["sector_data"].index[x],
"adj_close": data["sector_data"][x],
"sector_weight": weights[sectors_ticker][SPY_SECTORS_MAP[sector]],
}
records.append(record)
df = pd.DataFrame(records)
df["pct_change"] = df.groupby("sector")["adj_close"].pct_change()
df["contribution"] = df["pct_change"] * df["sector_weight"]
contributions = df.groupby("sector").agg({"contribution": "sum"})
contributions["contribution_as_pct"] = (
contributions["contribution"] / abs(df["contribution"].sum())
) * 100
return contributions
@log_start_end(log=logger)
def get_portfolio_sector_contributions(
start_date, portfolio_trades: pd.DataFrame
) -> pd.DataFrame:
"""Calculate sector contributions for the loaded portfolio for a fixed period. This is done
by calculating the daily attribution of each asset (% change in adj_close * Weight in PF)
then grouping by sector and summing the contribution.
Parameters
----------
start_data : str ('yyyy-mm-dd') or datetime.date
start date for calculating contributions from
portfolio_trades : dataframe
dataframe of trades in the loaded portfolio
Returns
-------
contributions : pd.DataFrame
dataframe with portfolio raw contributions
"""
# Cast portfolio_trades "Date" to datetime64[ns]
portfolio_trades["Date"] = pd.to_datetime(portfolio_trades["Date"])
contrib_df = pd.DataFrame()
asset_tickers = list(portfolio_trades["Ticker"].unique())
first_price = portfolio_trades["Date"].min()
price_data = pd.DataFrame(
yf.download(asset_tickers, start=first_price, progress=False)["Adj Close"]
) # returns series when one ticker, hence cast to df
# if there is only one ticker the column name is "Adj Close" instead of the ticker,
# if so it needs to be renamed to allow the df multiplication to work
if len(asset_tickers) == 1:
price_data = price_data.rename(columns={"Adj Close": asset_tickers[0]})
price_change = price_data.pct_change()
# Create a wide dataframe of shares owned on each day
cumulative_positions = portfolio_trades.copy()
cumulative_positions["Quantity"] = cumulative_positions.groupby("Ticker")[
"Quantity"
].cumsum()
cumulative_positions_wide = pd.pivot(
cumulative_positions, index="Date", columns="Ticker", values="Quantity"
)
index = pd.date_range(start=first_price, end=datetime.now(), freq="1D")
contrib_df = cumulative_positions_wide.reindex(index).ffill(axis=0)
# Multiply shares by price to get market cap of holdings on day
contrib_df = contrib_df * price_data
# turn daily market caps market caps into portfolio weights
contrib_df = contrib_df.div(contrib_df.sum(axis=1), axis=0)
# multiply by pct change in price to get daily attribution
contrib_df = contrib_df * price_change
# Wide to Long for aggregation
contrib_df = contrib_df.reset_index()
contrib_df = contrib_df.rename(columns={"index": "date"})
# filter passed off desired date here
contrib_df["date"] = contrib_df["date"].dt.date
contrib_df = contrib_df[~(contrib_df["date"] < start_date)]
# melt on datetime field
contrib_df = pd.melt(contrib_df, id_vars="date")
# # Get Sectors
sector_df = (
portfolio_trades[["Ticker", "Sector"]]
.groupby("Ticker")
.agg({"Sector": "min"})
.reset_index()
)
contrib_df = pd.merge(contrib_df, sector_df)
contrib_df = contrib_df.rename(columns={"value": "contribution"})
contrib_df = contrib_df.groupby("Sector").agg({"contribution": "sum"})
contrib_df["contribution_as_pct"] = (
contrib_df["contribution"] / abs(contrib_df["contribution"].sum())
) * 100
contrib_df = contrib_df.rename(index=PF_SECTORS_MAP)
contrib_df = contrib_df.reindex(PF_SECTORS_MAP.values())
contrib_df = contrib_df.fillna(0)
return contrib_df
@log_start_end(log=logger)
def percentage_attrib_categorizer(
bench_df: pd.DataFrame, pf_df: pd.DataFrame
) -> pd.DataFrame:
"""
Merge S&P500 benchmark attribution and portfolio attribution dataframes and calculates
excess attribution, attribution ratio, attribution direction and attribution sensitivity.
Returns attribution results as a proportion of the portfolio.
for example if a PF returns 1% and the raw attribution of a sector is 0.5% the result for the
sector is 50%.
Parameters
----------
bench_df : pd.DataFrame
S&P500 attribution dataframe
pf_df : pd.DataFrame
portfolio attribution dataframe
Returns
-------
result : pd.DataFrame
dataframe of S&P500 and PF attribution as a proportion
"""
# using percentage contributions
bench_df = bench_df.iloc[:, [1]]
pf_df = pf_df.iloc[:, [1]]
# rename columns
bench_df.rename(columns={"contribution_as_pct": "S&P500 [%]"}, inplace=True)
pf_df.rename(columns={"contribution_as_pct": "Portfolio [%]"}, inplace=True)
result = bench_df.join(pf_df)
# 1. Excess Attribution
result["Excess Attribution"] = result["Portfolio [%]"] - result["S&P500 [%]"]
# 2. Attribution Ratio
result["Attribution Ratio"] = result["Portfolio [%]"] / result["S&P500 [%]"]
# 3. Attribution Direction
direction = []
for ratio in result["Attribution Ratio"]:
if ratio >= 0:
direction.append("Correlated (+)")
elif ratio < 0:
direction.append("Uncorrelated (-)")
result["Attribution Direction [+/-]"] = direction
# 4. Attribution Sensetivity
sensitivity = []
for ratio in result["Attribution Ratio"]:
if abs(ratio) > 1.25:
sensitivity.append("High")
elif 0.75 <= abs(ratio) <= 1.25:
sensitivity.append("Normal")
elif abs(ratio) < 0.75:
sensitivity.append("Low")
result["Attribution Sensitivity"] = sensitivity
# 5. round values before output
result["S&P500 [%]"] = result["S&P500 [%]"].astype(float).round(2)
result["Portfolio [%]"] = result["Portfolio [%]"].astype(float).round(2)
return result
@log_start_end(log=logger)
def raw_attrib_categorizer(bench_df, pf_df) -> pd.DataFrame:
"""Merge S&P500 benchmark attribution and portfolio attribution dataframes and calculates
excess attribution, attribution ratio, attribution direction and attribution sensitivity.
Returns attribution results as raw values
for example if a PF returns 1% and the raw attribution of a sector is 0.5% the result for the
sector is 0.5
Parameters
----------
bench_df : pd.DataFrame
S&P500 attribution dataframe
pf_df : pd.DataFrame
portfolio attribution dataframe
Returns
-------
result : pd.DataFrame
dataframe of S&P500 and PF attribution as raw values.
"""
bench_df = bench_df.iloc[:, [0]]
pf_df = pf_df.iloc[:, [0]]
# rename columns
bench_df.rename(columns={"contribution": "S&P500"}, inplace=True)
pf_df.rename(columns={"contribution": "Portfolio"}, inplace=True)
result = bench_df.join(pf_df)
# 1. Excess Attribution
result["Excess Attribution"] = result["Portfolio"] - result["S&P500"]
# 2. Attribution Ratio
result["Attribution Ratio"] = result["Portfolio"] / result["S&P500"]
# 3. Attribution Direction
direction = []
for ratio in result["Attribution Ratio"]:
if ratio >= 0:
direction.append("Correlated (+)")
elif ratio < 0:
direction.append("Uncorrelated (-)")
result["Attribution Direction [+/-]"] = direction
# 4. Attribution Sensetivity
sensitivity = []
for ratio in result["Attribution Ratio"]:
if abs(ratio) > 1.25:
sensitivity.append("High")
elif 0.75 <= abs(ratio) <= 1.25:
sensitivity.append("Normal")
elif abs(ratio) < 0.75:
sensitivity.append("Low")
result["Attribution Sensitivity"] = sensitivity
# 5. round values before output
result["S&P500"] = result["S&P500"].astype(float).round(4)
result["Portfolio"] = result["Portfolio"].astype(float).round(4)
return result
@log_start_end(log=logger)
def get_daily_sector_prices(start_date, end_date) -> dict:
"""Fetch daily sector prices for S&P500 for a fixed time period
Parameters
----------
start_date : str ('yyyy-mm-dd') or datetime.date
start date for fetching data
end_date : str ('yyyy-mm-dd') or datetime.date
end date for fetching data
Returns
-------
sp500_tickers_data : dict
dictionary of dataframes with SPY daily sector prices
"""
# sector ticker information
sp500_tickers = {
"S&P 500 Materials (Sector)": "^SP500-15",
"S&P 500 Industrials (Sector)": "^SP500-20",
"S&P 500 Consumer Discretionary (Sector)": "^SP500-25",
"S&P 500 Consumer Staples (Sector)": "^SP500-30",
"S&P 500 Health Care (Sector)": "^SP500-35",
"S&P 500 Financials (Sector)": "^SP500-40",
"S&P 500 Information Technology (Sector)": "^SP500-45",
"S&P 500 Telecommunication Services (Sector)": "^SP500-50",
"S&P 500 Utilities (Sector)": "^SP500-55",
"S&P 500 Real Estate (Sector)": "^SP500-60",
"S&P 500 Energy (Sector)": "^GSPE",
}
sp500_tickers_data = {} # to store data
for (
sector,
sector_ticker,
) in sp500_tickers.items(): # iterate thru the sectors
# load the data required from yfinance
sp500_tickers_data[
sector
] = { # builds a dictionary entry for the sector with adj close data
"sector_data": yf.download(
sector_ticker,
start=start_date,
end=end_date,
progress=False,
)["Adj Close"]
} # stores the data here
return sp500_tickers_data