Files
OpenBB/openbb_terminal/stocks/fundamental_analysis/dcf_model.py
Colin Delahunty 36a5166d1b Fix dcf (#3223)
* Added suggestions for load

* Fixed a command

* Fixed panel

* Added fix for dcf

* Doing my boi a favor

Co-authored-by: James Maslek <jmaslek11@gmail.com>
Co-authored-by: Andrew <andrew.kenreich@gmail.com>
2022-11-01 14:45:25 +00:00

602 lines
14 KiB
Python

""" DCF Model """
__docformat__ = "numpy"
import logging
import os
from io import BytesIO
from pathlib import Path
from typing import Any, Dict, List, Tuple, Union
from urllib.request import urlopen
from zipfile import ZipFile
import financedatabase as fd
import pandas as pd
import requests
import yfinance as yf
from bs4 import BeautifulSoup
from openpyxl import worksheet
from sklearn.linear_model import LinearRegression
from openbb_terminal.decorators import log_start_end
from openbb_terminal.stocks.fundamental_analysis import dcf_static
from openbb_terminal.helper_funcs import compose_export_path
logger = logging.getLogger(__name__)
CURRENCIES = [
"ALL",
"AFN",
"ARS",
"AWG",
"AUD",
"AZN",
"BSD",
"BBD",
"BYN",
"BZD",
"BMD",
"BOB",
"BAM",
"BWP",
"BGN",
"BRL",
"BND",
"KHR",
"CAD",
"KYD",
"CLP",
"CNY",
"COP",
"CRC",
"HRK",
"CUP",
"CZK",
"DKK",
"DOP",
"XCD",
"EGP",
"SVC",
"EUR",
"FKP",
"FJD",
"GHS",
"GIP",
"GTQ",
"GGP",
"GYD",
"HNL",
"HKD",
"HUF",
"ISK",
"INR",
"IDR",
"IRR",
"IMP",
"ILS",
"JMD",
"JPY",
"JEP",
"KZT",
"KPW",
"KRW",
"KGS",
"LAK",
"LBP",
"LRD",
"MKD",
"MYR",
"MUR",
"MXN",
"MNT",
"MNT",
"MZN",
"NAD",
"NPR",
"ANG",
"NZD",
"NIO",
"NGN",
"NOK",
"OMR",
"PKR",
"PAB",
"PYG",
"PEN",
"PHP",
"PLN",
"QAR",
"RON",
"RUB",
"SHP",
"SAR",
"RSD",
"SCR",
"SGD",
"SBD",
"SOS",
"KRW",
"ZAR",
"LKR",
"SEK",
"CHF",
"SRD",
"SYP",
"TWD",
"THB",
"TTD",
"TRY",
"TVD",
"UAH",
"AED",
"GBP",
"USD",
"UYU",
"UZS",
"VEF",
"VND",
"YER",
"ZWD",
]
@log_start_end(log=logger)
def string_float(string: str) -> float:
"""Convert a string to a float
Parameters
----------
string : str
String to be converted
Returns
-------
number : float
Analysis of filings text
"""
if string.strip().replace(",", "").replace("-", "") == "":
return 0
return float(string.strip().replace(",", "").replace("-", ""))
def insert_row(
name: str, index: str, df: pd.DataFrame, row_v: List[str]
) -> pd.DataFrame:
"""Allows a row to be added given an index and name
Parameters
----------
name : str
Name to be added to df
index : str
The row the new item will go after
df : pd.DataFrame
The dataframe to be modified
row_v : List[str]
The items to be added to the row
Returns
-------
new_df : pd.DataFrame
The new dataframe
"""
pd.options.mode.chained_assignment = None
if name not in df.index:
row_number = df.index.get_loc(index) + 1
df1 = df[0:row_number]
df2 = df[row_number:]
df1.loc[name] = row_v
df_result = pd.concat([df1, df2])
return df_result
return df
@log_start_end(log=logger)
def set_cell(
ws: worksheet,
cell: str,
text: Union[int, str, float] = None,
font: str = None,
border: str = None,
fill: str = None,
alignment: str = None,
num_form: str = None,
):
"""Set the value for a cell
Parameters
----------
ws : worksheet
The worksheet to be modified
cell : str
The cell that will be modified
text : Union[int, str, float]
The new value of the cell
font : str
The type of font
border : str
The type of border
fill : str
The type of fill
alignment : str
The type of alignment
num_form : str
The format for numbers
"""
if text:
ws[cell] = text
if font:
ws[cell].font = font
if border:
ws[cell].border = border
if fill:
ws[cell].fill = fill
if alignment:
ws[cell].alignment = alignment
if num_form:
ws[cell].number_format = num_form
@log_start_end(log=logger)
def get_fama_raw() -> pd.DataFrame:
"""Get Fama French data
Returns
-------
df : pd.DataFrame
Fama French data
"""
with urlopen( # nosec
"https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_CSV.zip"
) as url:
# Download Zipfile and create pandas DataFrame
with ZipFile(BytesIO(url.read())) as zipfile:
with zipfile.open("F-F_Research_Data_Factors.CSV") as zip_open:
df = pd.read_csv(
zip_open,
header=0,
names=["Date", "MKT-RF", "SMB", "HML", "RF"],
skiprows=3,
)
df = df[df["Date"].apply(lambda x: len(str(x).strip()) == 6)]
df["Date"] = df["Date"].astype(str) + "01"
df["Date"] = pd.to_datetime(df["Date"], format="%Y%m%d")
df["MKT-RF"] = pd.to_numeric(df["MKT-RF"], downcast="float")
df["SMB"] = pd.to_numeric(df["SMB"], downcast="float")
df["HML"] = pd.to_numeric(df["HML"], downcast="float")
df["RF"] = pd.to_numeric(df["RF"], downcast="float")
df["MKT-RF"] = df["MKT-RF"] / 100
df["SMB"] = df["SMB"] / 100
df["HML"] = df["HML"] / 100
df["RF"] = df["RF"] / 100
df = df.set_index("Date")
return df
@log_start_end(log=logger)
def get_historical_5(symbol: str) -> pd.DataFrame:
"""Get 5 year monthly historical performance for a ticker with dividends filtered
Parameters
----------
symbol: str
The ticker symbol to be analyzed
Returns
-------
df: pd.DataFrame
Historical data
"""
tick = yf.Ticker(symbol)
df = tick.history(period="5y", interval="1mo")
df = df[df.index.to_series().apply(lambda x: x.day == 1)]
df = df.drop(["Dividends", "Stock Splits"], axis=1)
df = df.dropna()
return df
@log_start_end(log=logger)
def get_fama_coe(symbol: str) -> float:
"""Use Fama and French to get the cost of equity for a company
Parameters
----------
symbol : str
The ticker symbol to be analyzed
Returns
-------
coef : float
The stock's Fama French coefficient
"""
df_f = get_fama_raw()
df_h = get_historical_5(symbol)
df = df_h.join(df_f)
df = df.dropna()
df["Monthly Return"] = df["Close"].pct_change()
df["Excess Monthly Return"] = df["Monthly Return"] - df["RF"]
df = df.dropna()
x = df[["MKT-RF", "SMB", "HML"]]
y = df["Excess Monthly Return"]
model = LinearRegression().fit(x, y)
coefs = model.coef_
return (
df["RF"].mean()
+ coefs[0] * df["MKT-RF"].mean()
+ coefs[1] * df["SMB"].mean()
+ coefs[2] * df["HML"].mean()
) * 12
@log_start_end(log=logger)
def others_in_sector(
symbol: str, sector: str, industry: str, no_filter: bool = False
) -> List[str]:
"""Get other stocks in a ticker's sector
Parameters
----------
symbol: str
The ticker symbol to be excluded
sector: str
The sector to pull from
industry: str
The industry to pull from
no_filter: bool
True means that we do not filter based on market cap
Returns
-------
List[str]
List of symbols in the same sector
"""
industry = industry.replace("—", " - ")
industry = industry.replace("/", " ")
similars = fd.select_equities(sector=sector, industry=industry)
# This filters similars to match market cap and removes ticker analyzed
if symbol in similars:
market_cap = similars[symbol]["market_cap"]
similars.pop(symbol, None)
if not no_filter:
similars = {
k: v for (k, v) in similars.items() if v["market_cap"] == market_cap
}
similars = list(similars)
return similars
def create_dataframe(symbol: str, statement: str, period: str = "annual"):
"""
Creates a df financial statement for a given ticker
Parameters
----------
symbol : str
The ticker symbol to create a dataframe for
statement : str
The financial statement dataframe to create
period : str
Whether to look at annual, quarterly, or trailing
Returns
-------
statement : pd.DataFrame
The financial statement requested
rounding : int
The amount of rounding to use
statement_currency: str
The currency the financial statements are reported in
"""
if statement not in ["BS", "CF", "IS"]:
raise ValueError("statement variable must be 'BS', 'CF' or 'IS'")
if period not in ["annual", "quarterly", "trailing"]:
raise ValueError(
"statement variable must be 'annual','quarterly', or 'trailing'"
)
per_url = f"{period}/" if period != "annual" else ""
URL = f"https://stockanalysis.com/stocks/{symbol}/financials/"
URL += dcf_static.statement_url[statement] + per_url
ignores = dcf_static.statement_ignore[statement]
r = requests.get(URL, headers=dcf_static.headers)
if "404 - Page Not Found" in r.text:
return pd.DataFrame(), None, None
try:
df = pd.read_html(r.text)[0]
except ValueError:
return pd.DataFrame(), None, None
soup = BeautifulSoup(r.content, "html.parser")
phrase = soup.find(
"div", attrs={"class": "hidden pb-1 text-sm text-gray-600 lg:block"}
)
phrase = phrase.get_text().lower() if phrase else ""
if "thousand" in phrase:
rounding = 1_000
elif "millions" in phrase:
rounding = 1_000_000
elif "billions" in phrase:
rounding = 1_000_000_000
else:
return pd.DataFrame(), None, None
statement_currency = ""
for currency in CURRENCIES:
if currency.lower() in phrase:
statement_currency = currency
break
if "Quarter Ended" in df.columns:
df = df.set_index("Quarter Ended")
elif "Quarter Ending" in df.columns:
df = df.set_index("Quarter Ending")
else:
df = df.set_index("Year")
df = df.loc[:, ~(df == "Upgrade").any()]
for ignore in ignores:
if ignore in df.index:
df = df.drop([ignore])
df = df[df.columns[::-1]]
if statement == "IS":
vals = ["Revenue", dcf_static.gaap_is]
elif statement == "BS":
vals = ["Cash & Equivalents", dcf_static.gaap_bs]
elif statement == "CF":
vals = ["Net Income", dcf_static.gaap_cf]
if vals[0] in df.index:
blank_list = ["0" for _ in df.loc[vals[0]].to_list()]
else:
return pd.DataFrame(), None, None
for i, _ in enumerate(vals[1][1:]):
df = insert_row(vals[1][i + 1], vals[1][i], df, blank_list)
return df, rounding, statement_currency
@log_start_end(log=logger)
def get_similar_dfs(symbol: str, info: Dict[str, Any], n: int, no_filter: bool = False):
"""
Get dataframes for similar companies
Parameters
----------
symbol : str
The ticker symbol to create a dataframe for
into : Dict[str,Any]
The dictionary produced from the yfinance.info function
n : int
The number of similar companies to produce
no_filter : bool
True means that we do not filter based on market cap
Returns
-------
new_list : List[str, pd.DataFrame]
A list of similar companies
"""
similars = others_in_sector(symbol, info["sector"], info["industry"], no_filter)
i = 0
new_list = []
while i < n and similars:
similar_ret = [create_dataframe(similars[0], x)[0] for x in ["BS", "IS", "CF"]]
blank = [x.empty for x in similar_ret]
if True not in blank:
vals = [similars[0], similar_ret]
new_list.append(vals)
i += 1
similars.pop(0)
return new_list
@log_start_end(log=logger)
def clean_dataframes(*args) -> List[pd.DataFrame]:
"""
All dataframes in the list take on the length of the shortest dataframe
Parameters
----------
*args : List[pd.DataFrame]
List of dataframes to clean
Returns
-------
dfs : List[pd.DataFrame]
Cleaned list of dataframes
"""
min_cols = min(x.shape[1] for x in args)
dfs = [x.iloc[:, -min_cols:] for x in args]
return dfs
@log_start_end(log=logger)
def get_value(df: pd.DataFrame, row: str, column: int) -> Tuple[float, float]:
"""
Gets a specific value from the dataframe
Parameters
----------
df : pd.DataFrame
The dataframe to get the information from
row : str
The row to get the information from
column : int
The column to get the information from
Returns
-------
value : List[float]
The information in float format
"""
val1 = df.at[row, df.columns[column]]
if isinstance(val1, str):
fin_val1: float = float(val1.replace(",", "").replace("-", "-0"))
else:
fin_val1 = float(val1)
val2 = df.at[row, df.columns[column + 1]]
if isinstance(val2, str):
fin_val2: float = float(val2.replace(",", "").replace("-", "-0"))
else:
fin_val2 = float(val2)
return fin_val1, fin_val2
@log_start_end(log=logger)
def frac(num: float, denom: float) -> Union[str, float]:
"""
Converts a numerator and a denominator in a fraction, checking for invalid denominators
Parameters
----------
num : float
The numerator
denom : float
The denominator
Returns
-------
value : Union[str, float]
The fraction
"""
return "N/A" if denom == 0 else num / denom
@log_start_end(log=logger)
def generate_path(n: int, symbol: str, date: str) -> Path:
"""
Create the path to save an excel file to
Parameters
----------
n: int
The try number
symbol: str
The ticker symbol to be saved
date: str
The date the dcf was generated
Returns
-------
path: Path
The path to save a file to
"""
val = "" if n == 0 else f"({n})"
export_folder = compose_export_path(
func_name="dcf", dir_path=os.path.abspath(os.path.dirname(__file__))
).parent
trypath = export_folder / symbol / date / val
trypath = str(trypath) + ".xlsx" # type: ignore
return Path(trypath)