DCF Valuation Model
Overview
This is a focused discounted cash flow (DCF) tool that turns a stock ticker into an intrinsic value per share. I built it as a Dash page (it registers itself at the /dcf route) that pulls financials live from Yahoo Finance via yfinance, projects free cash flow out for a configurable number of years, discounts everything back at a chosen rate, bolts on a Gordon-growth terminal value, and divides the total present value by shares outstanding. It is for me as a hands-on investor who wants to sanity-check a market price against a quick, transparent intrinsic estimate, with sliders for the four assumptions that actually matter (discount rate, terminal growth, forecast horizon, and FCF growth) so I can see how sensitive the answer is.
Architecture and data flow
The whole thing lives in one file and is a single self-registering Dash page, so there is no separate controller layer. The flow end to end is:
- The page registers itself with
dash.register_page(__name__, path="/dcf", ...)so the parent app picks it up automatically through the Dash pages registry. The NavLink href inapp.pyhas to match/dcf. - The
layoutrenders acontrolscard (ticker input, a Run button, and fourdcc.Slidercontrols) wrapped in adcc.Loadingspinner that surrounds the emptyfd-resultsdiv. - When I click Run, the
run_dcfcallback fires. It reads the ticker asStateplus the four slider values, and callsfull_dcf_model(...). full_dcf_modelis where the real work happens: it hitsyf.Ticker, grabs the cash flow statement, derives free cash flow, runs the projection and discounting, and returns a dict of results (orNoneif there is no usable data).- Back in the callback, the result dict is formatted into a summary block plus a
dash_table.DataTableof the year-by-year projection, and pushed intofd-results. Any exception is caught and surfaced as a plain error string instead of blowing up the page.
There is also a fetch_all helper that pulls annual and quarterly income statement, balance sheet, and cash flow frames in one go. The DCF callback itself does not depend on it; the core model fetches its own cash flow directly, so fetch_all is there as a broader data-grab utility for the "Full Analyst Mode" framing.
The interesting internals
Free cash flow extraction with fuzzy row matching
Yahoo is wildly inconsistent about how it labels cash flow statement rows across tickers and over time, so the model normalizes the index to lowercase and then does substring matching against a list of likely names:
cf.index = [str(i).strip().lower() for i in cf.index]
possible_cfo = ["operating cash flow","total cash from operating activities"]
possible_capex = ["capital expenditure","capital expenditures","purchase of ppe"]
def find(df, names):
for n in names:
m = [i for i in df.index if n in i]
if m: return pd.to_numeric(df.loc[m[0]], errors="coerce")
return pd.Series(dtype=float)
cfo = find(cf, possible_cfo)
capex = -find(cf, possible_capex)
The find helper walks the candidate names in priority order and uses n in i substring matching, not equality, because the real labels are messy (extra words, punctuation, casing). The -find(...) on capex is the gotcha I had to handle: Yahoo reports capital expenditure as a negative number (cash out), so I flip its sign so that the later cfo - capex subtraction actually subtracts spend rather than adding it back. Everything is run through pd.to_numeric(..., errors="coerce") so stray non-numeric cells become NaN instead of crashing the math.
A free cash flow fallback
If neither the operating cash flow nor capex rows are found, but Yahoo happens to expose a precomputed "free cash flow" row, I use that directly:
if cfo.empty and capex.empty and "free cash flow" in cf.index:
fcf = pd.to_numeric(cf.loc["free cash flow"], errors="coerce").dropna()
else:
fcf = (cfo - capex).dropna()
if fcf.empty:
return None
fcf_latest = fcf.iloc[0]
This is a graceful-degradation pattern. The preferred path is bottom-up (CFO minus capex) because it is the textbook definition and I can see both components, but rather than return nothing when the labels do not match, I fall back to whatever Yahoo already computed. If even that is empty after dropping NaNs, the function returns None and the callback shows "No FCF data available." fcf.iloc[0] takes the most recent period because yfinance returns columns most-recent-first.
The projection and discounting loop
The core algorithm grows the latest FCF forward and discounts each year back to present value:
projection = []
for i in range(1, years + 1):
fcf_proj = fcf_latest * ((1 + growth) ** i)
discount_factor = (1 + discount) ** i
pv = fcf_proj / discount_factor
projection.append({
"Year": i,
"Projected FCF": fcf_proj,
"Discount Factor": discount_factor,
"PV of FCF": pv
})
proj_df = pd.DataFrame(projection)
I keep this as an explicit Python loop rather than a vectorized one-liner on purpose: I want each year's projected FCF, discount factor, and present value materialized as a row so the DataTable can show the full working. For a 3 to 10 year horizon the loop cost is irrelevant, and the auditability is worth far more than micro-performance.
Terminal value and fair value per share
After the explicit horizon, the bulk of the value is captured by a Gordon growth terminal value, discounted back and added to the sum of explicit present values:
terminal_value = (fcf_latest * ((1 + growth) ** (years + 1))) * (1 + term_growth) / (discount - term_growth)
pv_terminal = terminal_value / ((1 + discount) ** years)
total_pv = proj_df["PV of FCF"].sum() + pv_terminal
if pd.notna(shares_out) and shares_out > 0:
fair_value = total_pv / shares_out
else:
fair_value = np.nan
The terminal value grows the first post-horizon cash flow (years + 1) one more notch by (1 + term_growth) and capitalizes it at discount - term_growth. The shares_out guard is the defensive bit: if Yahoo gives me a missing or zero share count, dividing would produce inf or a ZeroDivisionError, so I short circuit to np.nan and let the formatting layer handle it.
Formatting that never crashes on missing data
Display values run through helpers that treat missing data as a dash rather than throwing:
def _fmt_num(x):
if x is None or pd.isna(x):
return "-"
x = float(x); ax = abs(x)
if ax >= 1_000_000_000_000: return f"{x/1_000_000_000_000:.2f}T"
if ax >= 1_000_000_000: return f"{x/1_000_000_000:.2f}B"
if ax >= 1_000_000: return f"{x/1_000_000:.2f}M"
if ax >= 1_000: return f"{x/1_000:.2f}K"
return f"{x:.0f}"
This collapses raw figures (often in the hundreds of billions) into readable T, B, M, K suffixes, and the None/pd.isna guard at the top means a NaN fair value or terminal value renders cleanly instead of breaking the summary string.
The formulas, spelled out
Let FCF_0 be the latest free cash flow, g the FCF growth rate, r the discount rate, g_t the terminal growth rate, and N the number of forecast years.
- Free cash flow: FCF = operating cash flow - capital expenditure (with capex sign-flipped from Yahoo's negative convention), or the reported free cash flow row as a fallback.
- Projected FCF in year i: FCF_i = FCF_0 * (1 + g)^i.
- Discount factor for year i: DF_i = (1 + r)^i.
- Present value of year i: PV_i = FCF_i / DF_i.
- Terminal value: TV = FCF_0 * (1 + g)^(N+1) * (1 + g_t) / (r - g_t).
- Present value of terminal value: PV_TV = TV / (1 + r)^N.
- Total enterprise present value: Total PV = sum of PV_i for i = 1..N, plus PV_TV.
- Fair value per share: Total PV / shares outstanding (NaN if shares are missing or zero).
One honest caveat in the current code: the fair value divides total present value straight by shares without first subtracting net debt, so the result behaves more like an enterprise-value-per-share figure than a strict equity value per share. That is a deliberate simplification in this version, and it is the obvious next thing to tighten up.
Tech stack: Python, Dash (pages, dcc, html, dash_table, callbacks), yfinance, pandas, numpy.