Financial Analysis Dashboard
Overview
This is a self-contained equity research workbench I built as a single Dash page. You type a ticker (it defaults to AAPL), pick annual or quarterly, hit Load Data, and it pulls the three financial statements from Yahoo Finance, renders them as sortable tables, computes profitability and growth metrics, lines the company up against a handful of industry peers, and then runs four step-by-step valuation models: P/E, EV/EBITDA, a full multi-year DCF, and a Gordon-growth DDM. The audience is me and anyone who wants to sanity-check a stock without opening a spreadsheet, so every valuation is shown as a transparent "Inputs then steps" table rather than a single magic number. There is a live methodology writeup linked at /methodology for the people who want the math behind the tables.
Architecture and data flow
The whole page is one file registered as a Dash Pages route at /financial-dashboard. There is no database and no persistent state. Everything is request-scoped and flows through a single callback.
The pipeline is: user input (ticker, period, and the valuation assumption boxes) lands in the run callback as Input and State. run calls fetch_all, which constructs a yf.Ticker, grabs .info, resolves a current price, and then calls fetch_statements to pull the income statement, balance sheet, and cash flow as pandas DataFrames. Those DataFrames feed compute_ratios and compute_growth for the metrics, fetch_peers plus fetch_peer_metrics for the comparison table, and valuation_inputs_defaults to seed the four valuation builders. Each builder returns a list of {"Step", "Value"} dicts, which df_table turns into a Dash DataTable. The callback returns nine outputs in one shot (company block, KPIs, three statement tables, ratio/growth table, peers, the stacked valuations, and a status string), all wrapped in a dcc.Loading spinner so the user sees a spinner while yfinance does its network round-trips.
The design choice worth calling out: there is no caching layer and no dcc.Store. Every click re-fetches. For a personal tool that keeps the code dead simple and always fresh, at the cost of a few seconds per load.
The interesting internals
1. The tolerant _first line-item lookup
def _first(df, keys, col):
for k in keys:
if k in df.index:
try:
return float(df.loc[k, col])
except Exception:
try:
return float(df.loc[k].iloc[0])
except Exception:
continue
return None
This is the single most important defensive piece in the file. yfinance does not give you a stable schema: the same line item shows up as "Total Liab" one week and "Total Liabilities Net Minority Interest" the next, and quarterly frames sometimes reorder columns. _first takes a list of candidate row labels in priority order and returns the first one that exists, coercing to float. The nested try/except is the gotcha handler: if indexing by the exact column fails (label present but that column is NaN or missing), it falls back to .iloc[0], the most recent period. If nothing matches, it returns None instead of throwing. That None convention is what lets every downstream ratio guard itself with if revenue else None rather than wrapping the whole dashboard in try/except. This is why you see candidate lists everywhere, for example the equity lookup: ["Total Stockholder Equity", "Stockholders Equity", "Total Equity Gross Minority Interest", "Common Stock Equity"].
2. Price resolution with a history fallback
def fetch_all(ticker, period="annual"):
ticker = ticker.strip().upper()
t = yf.Ticker(ticker)
info = t.info or {}
price = info.get("currentPrice")
if price is None:
try:
price = float(t.history(period="5d")["Close"].iloc[-1])
except Exception:
price = None
is_df, bs_df, cf_df = fetch_statements(ticker, period)
return info, price, is_df, bs_df, cf_df
info["currentPrice"] is frequently missing, especially for non-US tickers and after hours. Rather than show a blank price, I fall back to the last close from a 5-day history pull. The period="5d" window is deliberate: it is wide enough to survive a long weekend or a market holiday where a 1-day pull would come back empty, and .iloc[-1] always grabs the freshest close. If even that fails, price stays None and the UI prints a dash. Note also the info or {} guard, because yfinance can hand back None for .info on a bad ticker, and {} keeps every later .get safe.
3. CAGR and YoY growth, with the array reversal trick
def compute_growth(is_df):
if is_df.empty or "Total Revenue" not in is_df.index or "Net Income" not in is_df.index:
return {}
rev = is_df.loc["Total Revenue"].dropna().astype(float)
net = is_df.loc["Net Income"].dropna().astype(float)
if len(rev) < 2 or len(net) < 2:
return {}
rev = rev[::-1]
net = net[::-1]
years = len(rev)
cagr_rev = (rev.iloc[-1] / rev.iloc[0]) ** (1/(years-1)) - 1 if rev.iloc[0] else None
cagr_net = (net.iloc[-1] / net.iloc[0]) ** (1/(years-1)) - 1 if net.iloc[0] else None
yoy_rev = (rev.iloc[-1] / rev.iloc[-2] - 1) if rev.iloc[-2] else None
yoy_net = (net.iloc[-1] / net.iloc[-2] - 1) if net.iloc[-2] else None
The rev[::-1] reversal is the load-bearing line. yfinance returns statement columns newest-first (left to right), so without flipping the series, CAGR would run backwards and produce a nonsense negative rate for a growing company. After reversal the series is oldest to newest, so .iloc[0] is the base period and .iloc[-1] is the latest. The if rev.iloc[0] else None guards protect against division by zero and against the classic CAGR trap where the base is zero or the company had no revenue in the first year. Both .dropna() and the len < 2 short-circuit mean a company with only one usable year simply yields an empty dict instead of crashing.
4. The DCF, where enterprise value becomes equity value per share
fcf_list = []
pv_list = []
for t in range(1, years+1):
fcf = fcf_start * ((1 + growth) ** t)
pv = fcf / ((1 + discount) ** t)
fcf_list.append(fcf)
pv_list.append(pv)
terminal_value = (fcf_list[-1] * (1 + terminal_growth)) / (discount - terminal_growth) if discount and discount>terminal_growth else None
pv_terminal = terminal_value / ((1 + discount) ** years) if terminal_value else None
enterprise_value = sum(pv_list) + (pv_terminal or 0)
net_debt = (debt or 0) - (cash or 0)
equity_value = enterprise_value - net_debt
per_share = equity_value / shares if shares else None
This is a textbook two-stage DCF. Stage one grows free cash flow at growth for years periods and discounts each year back at the WACC. Stage two is a Gordon terminal value on the final projected year, then discounted back to today. The discount > terminal_growth guard is essential: the Gordon formula FCF*(1+g)/(r-g) blows up to infinity or goes negative when terminal growth meets or exceeds the discount rate, so I refuse to compute it in that case and the row shows a dash. The crucial accounting step is equity_value = enterprise_value - net_debt. The cash flows belong to the whole firm (debt and equity holders), so the present value is enterprise value. To get to what a shareholder owns, you subtract net debt (debt - cash) before dividing by shares. EV/EBITDA does the exact same bridge with equity_value = ev - ((debt or 0) - (cash or 0)). Getting that subtraction right is what separates a real valuation from a back-of-envelope multiple.
5. The free cash flow derivation fallback in the callback
fcf_start = None
try:
col_cf = cf_df.columns[0]
fcf_start = _first(cf_df, ["Free Cash Flow"], col_cf)
if fcf_start is None:
ocf = _first(cf_df, ["Total Cash From Operating Activities", "Operating Cash Flow"], col_cf)
capex = _first(cf_df, ["Capital Expenditures"], col_cf)
if ocf is not None and capex is not None:
fcf_start = ocf - abs(capex)
except Exception:
pass
Yahoo sometimes reports "Free Cash Flow" directly and sometimes does not. When it is absent, I reconstruct it from first principles: operating cash flow minus capital expenditure. The abs(capex) is the gotcha, because capex is reported as a negative number on the cash flow statement (it is an outflow), so a naive ocf + capex would accidentally do the right thing while ocf - capex would double-count it. By forcing abs() and subtracting, the intent is explicit and correct regardless of the sign yfinance chooses to use. If both pieces are missing, fcf_start stays None and the DCF table just shows inputs without a result.
The algorithms and formulas
- Net Margin = Net Income / Revenue. ROE = Net Income / Equity. ROA = Net Income / Total Assets. Current Ratio = Current Assets / Current Liabilities.
- Debt-to-Equity prefers Total Debt / Equity, and falls back to Total Liabilities / Equity when Total Debt is unavailable.
- Revenue CAGR = (last / first) ^ (1 / (years - 1)) - 1, computed on the oldest-to-newest series.
- Year over year = (latest / prior) - 1.
- P/E valuation: implied price = EPS * P/E multiple, where EPS = net income / shares; upside = implied / price - 1.
- EV/EBITDA: EV = EBITDA * multiple; equity value = EV - net debt; price per share = equity value / shares.
- DCF: for each year t in 1..N, FCF_t = FCF_0 * (1 + g) ^ t and PV_t = FCF_t / (1 + r) ^ t. Terminal value = FCF_N * (1 + g_term) / (r - g_term), discounted by (1 + r) ^ N. Enterprise value = sum(PV_t) + PV(terminal); equity value = EV - net debt; per share = equity / shares.
- DDM (Gordon): intrinsic value = D1 / (r - g), where D1 is next year's dividend = current dividend * (1 + g), and the model only computes when r > g.
Tech stack: Python, Dash 3.x Pages, dash_table, pandas, yfinance, and the math standard library.