mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-29 05:21:48 +00:00
* 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
393 lines
12 KiB
Python
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
|