├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── index.html └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Prateek Malhotra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The fastest DCF calculator, ever. 2 | Basic Discounted Cash Flow model to quickly value public companies. It's [live](https://prateekmalhotra.me/dcf-basic/)! 3 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from lxml import html 2 | import requests 3 | import pandas as pd 4 | import yfinance 5 | import json 6 | import argparse 7 | import streamlit as st 8 | import numpy as np 9 | from collections import OrderedDict 10 | 11 | from htbuilder import HtmlElement, div, ul, li, br, hr, a, p, img, styles, classes, fonts 12 | from htbuilder.units import percent, px 13 | from htbuilder.funcs import rgba, rgb 14 | 15 | import warnings 16 | warnings.filterwarnings('ignore') 17 | 18 | def image(src_as_string, **style): 19 | return img(src=src_as_string, style=styles(**style)) 20 | 21 | def link(link, text, **style): 22 | return a(_href=link, _target="_blank", style=styles(**style))(text) 23 | 24 | def layout(*args): 25 | 26 | style = """ 27 | 31 | """ 32 | 33 | style_div = styles( 34 | left=0, 35 | bottom=0, 36 | margin=px(0, 0, 0, 0), 37 | width=percent(100), 38 | color="white", 39 | text_align="center", 40 | height="auto", 41 | opacity=1 42 | ) 43 | 44 | style_hr = styles( 45 | display="block", 46 | margin=px(8, 8, "auto", "auto"), 47 | border_style="inset", 48 | border_width=px(2) 49 | ) 50 | 51 | body = p() 52 | foot = div( 53 | style=style_div 54 | )( 55 | hr( 56 | style=style_hr 57 | ), 58 | body 59 | ) 60 | 61 | st.markdown(style, unsafe_allow_html=True) 62 | 63 | for arg in args: 64 | if isinstance(arg, str): 65 | body(arg) 66 | 67 | elif isinstance(arg, HtmlElement): 68 | body(arg) 69 | 70 | st.markdown(str(foot), unsafe_allow_html=True) 71 | 72 | 73 | def footer(): 74 | myargs = [ 75 | "Made ", 76 | " with ❤️ by ", 77 | link("https://prateekmalhotra.me", "Prateek Malhotra"), 78 | br(), 79 | br(), 80 | "I enjoy making these tools for free but some ", 81 | link("https://buymeacoffee.com/prateekm08", "pocket money"), 82 | " would sure be appreciated!" 83 | ] 84 | layout(*myargs) 85 | 86 | def parse(ticker): 87 | url = "https://stockanalysis.com/stocks/{}/financials/cash-flow-statement".format(ticker) 88 | response = requests.get(url, verify=False) 89 | parser = html.fromstring(response.content) 90 | 91 | # Cash Flows 92 | 93 | op_fcfs = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Operating Cash Flow")]]')[0].xpath('.//td/span/text()')[1:] 94 | capexs = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Capital Expenditures")]]')[0].xpath('.//td/span/text()')[1:] 95 | dbt = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Debt Issued / Paid")]]')[0].xpath('.//td/span/text()')[1:] 96 | net_income = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Net Income")]]')[0].xpath('.//td/span/text()')[1:] 97 | 98 | op_fcfs = [float(x.replace(',', '')) for x in op_fcfs] 99 | capexs = [float(x.replace(',', '')) for x in capexs] 100 | dbt = [float(x.replace(',', '')) for x in dbt] 101 | net_income = [float(x.replace(',', '')) for x in net_income] 102 | 103 | fcfs_equity = list(np.array(op_fcfs) + np.array(capexs)) # + np.array(dbt) (difficult to predict when company will borrow money) 104 | 105 | # Revenues 106 | 107 | url = "https://stockanalysis.com/stocks/{}/financials".format(ticker) 108 | response = requests.get(url, verify=False) 109 | parser = html.fromstring(response.content) 110 | 111 | revenues = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Revenue")]]')[0].xpath('.//td/span/text()')[1:] 112 | revenues = [float(x.replace(',', '')) for x in revenues] 113 | 114 | # Debt 115 | 116 | url = "https://stockanalysis.com/stocks/{}/financials/balance-sheet".format(ticker) 117 | response = requests.get(url, verify=False) 118 | parser = html.fromstring(response.content) 119 | 120 | net_debt = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "Net Cash / Debt")]]')[0].xpath('.//td/span/text()')[1:] 121 | net_debt = [float(x.replace(',', '')) for x in net_debt][0] 122 | 123 | url = "https://finance.yahoo.com/quote/{}/analysis?p={}".format(ticker, ticker) 124 | response = requests.get(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:20.0) Gecko/20100101 Firefox/20.0'}) 125 | parser = html.fromstring(response.content) 126 | tables = parser.xpath('//table') 127 | 128 | for t in tables: 129 | if t.xpath("thead//tr//th/span/text()")[0] == 'Revenue Estimate': 130 | vals = t.xpath("tbody//tr//td/span/text()") 131 | ind = vals.index("Low Estimate") 132 | res = [vals[ind - 2], vals[ind - 1]] 133 | 134 | for i in range(len(res)): 135 | if 'B' in res[i]: 136 | res[i] = float(res[i].replace('B', '')) * 1000 137 | elif 'M' in res[i]: 138 | res[i] = float(res[i].replace('M', '')) 139 | 140 | break 141 | 142 | ge = tables[-1].xpath("tbody//tr") 143 | 144 | for row in ge: 145 | label = row.xpath("td/span/text()")[0] 146 | 147 | if 'Next 5 Years' in label: 148 | try: 149 | ge = float(row.xpath("td/text()")[0].replace('%', '')) 150 | except: 151 | ge = [] 152 | break 153 | 154 | url = "https://stockanalysis.com/stocks/{}/".format(ticker) 155 | response = requests.get(url, verify=False) 156 | parser = html.fromstring(response.content) 157 | shares = parser.xpath('//div[@class="order-1 flex flex-row gap-4"]//table//tbody//tr[td/text()[contains(., "Shares Out")]]') 158 | 159 | shares = shares[0].xpath('td/text()')[1] 160 | factor = 1000 if 'B' in shares else 1 161 | shares = float(shares.replace('B', '').replace('M', '')) * factor 162 | 163 | url = "https://stockanalysis.com/stocks/{}/financials/".format(ticker) 164 | response = requests.get(url, verify=False) 165 | parser = html.fromstring(response.content) 166 | eps = parser.xpath('//table[contains(@id,"financial-table")]//tr[td/span/text()[contains(., "EPS (Diluted)")]]')[0].xpath('.//td/span/text()')[1:] 167 | eps = float(eps[0].replace(",", "")) 168 | 169 | try: 170 | market_price = float(parser.xpath('//div[@class="price-ext"]/text()')[0].replace('$', '').replace(',', '')) 171 | except: 172 | market_price = round(yfinance.Ticker(ticker).history().tail(1).Close.iloc[0], 2) 173 | 174 | return {'fcf': fcfs_equity, 'op_fcfs': op_fcfs, 'capexs': capexs, 'dbt': dbt, 'ni': net_income, 'revenues': revenues, 'nd': net_debt, 'res': res, 'ge': ge, 'yr': 5, 'dr': 10, 'pr': 2.5, 'shares': shares, 'eps': eps, 'mp': market_price} 175 | 176 | def dcf(data): 177 | fcf_ni_ratio = np.round(np.array(data['fcf'][:3]) / np.array(data['ni'][:3]), 2) * 100 178 | fcf_ni_ratio_deviation = np.std(fcf_ni_ratio) 179 | fcf_ni_correlation = np.corrcoef(data['fcf'][:3], data['ni'][:3]) # Direction 180 | 181 | if data['fcf'][0] < 0: 182 | st.warning("Free Cash Flow is negative") 183 | 184 | print(fcf_ni_ratio_deviation) 185 | if fcf_ni_ratio_deviation > 40: 186 | st.warning("Magnitude of change in FCF is not the same as that of Net Income. Fair Value might need more analysis from your side.") 187 | 188 | if fcf_ni_correlation[0][1] < 0.9: 189 | st.warning("FCF is not inline with profitability. Fair Value might not be too reliable.") 190 | 191 | ni_margins = np.round(np.array(data['ni']) / np.array(data['revenues']), 2) * 100 192 | 193 | forecast = [data['fcf'][1], data['fcf'][0]] 194 | rev_forecast_df = [data['revenues'][0], data['res'][0], data['res'][1]] 195 | net_income = [data['ni'][-1], data['ni'][0]] 196 | 197 | if data['ge'] == []: 198 | raise ValueError("No growth rate available from Yahoo Finance") 199 | 200 | for i in range(1, data['yr']): 201 | forecast.append(round(forecast[-1] + (data['ge'] / 100) * forecast[-1], 2)) 202 | net_income.append(round(net_income[-1] + (data['ge'] / 100) * net_income[-1], 2)) 203 | 204 | for i in range(1, data['yr'] - 1): 205 | rev_forecast_df.append(round(rev_forecast_df[-1] + (data['ge'] / 100) * rev_forecast_df[-1], 2)) 206 | 207 | rev_forecast_df = pd.DataFrame(np.array((rev_forecast_df, net_income)).T, columns=['Revenue estimate', 'Net Income']) 208 | 209 | forecast.append(round(forecast[-1] * (1 + (data['pr'] / 100)) / (data['dr'] / 100 - data['pr'] / 100), 2)) #terminal value 210 | discount_factors = [1 / (1 + (data['dr'] / 100))**(i + 1) for i in range(len(forecast) - 1)] 211 | 212 | pvs = [round(f * d, 2) for f, d in zip(forecast[:-1], discount_factors)] 213 | pvs.append(round(discount_factors[-1] * forecast[-1], 2)) # discounted terminal value 214 | 215 | cash_array = np.array((forecast[:-1], pvs[:-1])).T 216 | forecast_df = pd.DataFrame(cash_array, columns=['Forecasted Cash Flows', 'PV of Cash Flows']) 217 | 218 | st.markdown("### _Cash Flows_") 219 | st.line_chart(data=forecast_df) 220 | 221 | dcf = sum(pvs) + data['nd'] 222 | fv = round(dcf / data['shares'], 2) 223 | 224 | st.markdown("### _Income_ ") 225 | st.line_chart(data=rev_forecast_df) 226 | 227 | return fv 228 | 229 | def reverse_dcf(data): 230 | pass 231 | 232 | def graham(data): 233 | if data['eps'] > 0: 234 | expected_value = round(data['eps'] * (8.5 + 2 * (data['ge'])), 2) 235 | 236 | try: 237 | ge_priced_in = round((data['mp'] / data['eps'] - 8.5) / 2, 2) 238 | except: 239 | ge_priced_in = "N/A" 240 | 241 | st.write("Expected value based on growth rate: {}".format(expected_value)) 242 | st.write("Growth rate priced in for next 7-10 years: {}\n".format(ge_priced_in)) 243 | else: 244 | st.write("Not applicable since EPS is negative.") 245 | 246 | if __name__ == "__main__": 247 | st.title("Intrinsic Value Calculator") 248 | 249 | ticker_input_container = st.empty() 250 | ticker = ticker_input_container.text_input("Ticker", max_chars=7, help="Inset ticker symbol for the company you wish to value", placeholder="AAPL", value="AAPL") 251 | 252 | dr_input_container = st.empty() 253 | discount_rate = dr_input_container.number_input("Discount Rate (%)", min_value=0.0, max_value=100.0, format="%f", value=7.5, help="Insert discount rate (also called required rate of return)") 254 | 255 | ge_input_container = st.empty() 256 | growth_estimate = ge_input_container.number_input("Growth Estimate (%)", min_value=-100.0, max_value=100.0, format="%f", help="Estimated yoy growth rate. If left to -100, it will fetch from Yahoo Finance") 257 | 258 | tr_input_container = st.empty() 259 | terminal_rate = tr_input_container.number_input("Terminal Rate (%)", min_value=0.0, max_value=100.0, format="%f", help="Terminal growth rate.", value=2.5) 260 | 261 | pd_input_container = st.empty() 262 | period = pd_input_container.number_input("Time period (yrs)", min_value=0, max_value=25, format="%d", help="Time period for growth", value=5) 263 | 264 | fcf_choice_container = st.empty() 265 | fcf_choice = fcf_choice_container.selectbox("Initial FCF choice", ("Most Recent Free Cash Flow", "Average last 3 years", "Custom"), help="Chooses most recent FCF by default") 266 | 267 | yf_flag = True 268 | if growth_estimate != -100.0: 269 | yf_flag = False 270 | 271 | st.text("") 272 | compute_container = st.empty() 273 | 274 | if fcf_choice == "Custom": 275 | fcf = fcf_choice_container.number_input("Free Cash Flow (Custom) in millions", format="%f") 276 | 277 | if compute_container.button("Compute DCF (basic) valuation"): 278 | 279 | ticker_input_container.empty() 280 | dr_input_container.empty() 281 | ge_input_container.empty() 282 | tr_input_container.empty() 283 | pd_input_container.empty() 284 | compute_container.empty() 285 | fcf_choice_container.empty() 286 | 287 | data = parse(ticker) 288 | if fcf_choice == "Custom": 289 | data['fcf'][0] = fcf 290 | 291 | if fcf_choice == "Average last 3 years": 292 | data['fcf'][0] = np.mean(data['fcf'][:3]) 293 | 294 | if period is not None: 295 | data['yr'] = int(period) 296 | if yf_flag == False: 297 | data['ge'] = float(growth_estimate) 298 | if discount_rate is not None: 299 | data['dr'] = float(discount_rate) 300 | if terminal_rate is not None: 301 | data['pr'] = float(terminal_rate) 302 | 303 | col1, col2, col3, col4 = st.columns(4) 304 | 305 | with col1: 306 | st.metric("", "") 307 | st.metric(f"Ticker", ticker, delta=None, delta_color="normal") 308 | 309 | with col2: 310 | st.metric(f"Market Price", data['mp'], delta=None, delta_color="normal") 311 | 312 | if yf_flag: 313 | st.metric("Growth estimate (Yahoo Finance)", str(data['ge']) + " %", delta=None, delta_color="normal") 314 | else: 315 | st.metric("Growth estimate", str(data['ge']) + " %", delta=None, delta_color="normal") 316 | 317 | st.metric("Discount Rate", str(data['dr']) + " %", delta=None, delta_color="normal") 318 | 319 | 320 | with col3: 321 | st.metric("EPS", data['eps'], delta=None, delta_color="normal") 322 | st.metric("Term", str(data['yr']) + " years", delta=None, delta_color="normal") 323 | st.metric("Perpetual Rate", str(data['pr']) + " %", delta=None, delta_color="normal") 324 | 325 | 326 | fv = dcf(data) 327 | 328 | with col4: 329 | st.metric("", "") 330 | st.metric("Fair Value", fv) 331 | 332 | footer() 333 | 334 | # st.write("=" * 80) 335 | # st.write("Graham style valuation basic (Page 295, The Intelligent Investor)") 336 | # st.write("=" * 80 + "\n") 337 | 338 | # graham(data) 339 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 26 | 27 | 28 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | requests 3 | pandas 4 | yfinance 5 | streamlit 6 | numpy 7 | htbuilder 8 | --------------------------------------------------------------------------------