mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-14 19:08:06 +00:00
Complete migration from Streamlit to Next.js 14 App Router + FastAPI backend. Frontend (Next.js 14): - 10 pages: Overview, Research, Valuation, Technical, Markets, Earnings, News, Portfolio, Filings, Settings - Terminal Noir dark theme with custom Tailwind config - TradingView Lightweight Charts for candlestick/volume - Valuation: DCF, Sensitivity Matrix, Monte Carlo, Tornado, Reverse DCF - Financial Statements table with YoY growth badges and margin rows - SEC EDGAR inline filing viewer with section tabs - News split-view with iframe article embedding - Technical Analysis with RSI, MACD, Bollinger, Fibonacci, Moving Averages - Earnings beat/miss visualization - AI Copilot chat panel with Gemini integration Backend (FastAPI): - 13 routers: market_data, financials, valuation, technical, earnings, insider, edgar, news, portfolio, analysis, chat, estimates, fx - Services: DCF engine, Monte Carlo simulation, sensitivity analysis, risk metrics, SEC parser, technical indicators - yfinance + yahooquery data sources with fallback pattern - SQLite caching layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.5 KiB
Python
248 lines
7.5 KiB
Python
"""Discounted Cash Flow (DCF) valuation engine.
|
|
|
|
Implements multiple DCF model variants:
|
|
- Simple 5-year single-stage DCF
|
|
- 10-year two-stage DCF (growth fades from Stage 1 to terminal)
|
|
- Excel-style full DCF (EV -> Equity -> per-share value)
|
|
|
|
Also includes Damodaran sector WACC reference data and smart-default
|
|
assumption generation from CAPM beta and analyst growth estimates.
|
|
"""
|
|
|
|
from typing import Dict, List, Optional
|
|
|
|
from server.utils.safe_float import _safe_float
|
|
|
|
try:
|
|
from scipy.optimize import brentq
|
|
except ImportError:
|
|
brentq = None # type: ignore[assignment]
|
|
|
|
try:
|
|
import yfinance as yf
|
|
except ImportError:
|
|
yf = None # type: ignore[assignment]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Damodaran sector WACC reference (approx. 2024/2025 baseline)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DAMODARAN_WACC: Dict[str, float] = {
|
|
"Software": 8.5,
|
|
"Retail": 7.5,
|
|
"Hardware": 9.0,
|
|
"Financials": 8.0,
|
|
"Healthcare": 7.2,
|
|
"Consumer": 7.5,
|
|
"Technology": 8.5,
|
|
"Industrial": 7.8,
|
|
"Energy": 8.2,
|
|
"Utilities": 6.5,
|
|
}
|
|
|
|
DAMODARAN_ERP_PCT: float = 4.6
|
|
"""US Equity Risk Premium (Damodaran estimate)."""
|
|
|
|
DAMODARAN_RF_PCT: float = 4.2
|
|
"""10-year risk-free rate (Damodaran estimate)."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DCF models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def dcf_intrinsic_value(
|
|
fcf: float,
|
|
wacc: float,
|
|
terminal_growth: float,
|
|
fcf_growth: float,
|
|
years: int = 5,
|
|
) -> float:
|
|
"""5-year single-stage DCF returning enterprise value.
|
|
|
|
Projects FCF at *fcf_growth* for *years* periods, then computes a
|
|
Gordon Growth terminal value discounted at *wacc*.
|
|
"""
|
|
if fcf is None or fcf <= 0:
|
|
return 0.0
|
|
if wacc <= terminal_growth or wacc <= 0:
|
|
return 0.0
|
|
pv = 0.0
|
|
fcft = float(fcf)
|
|
for t in range(1, years + 1):
|
|
pv += fcft / ((1 + wacc) ** t)
|
|
fcft *= (1 + fcf_growth)
|
|
terminal_fcf = fcft
|
|
tv = terminal_fcf * (1 + terminal_growth) / (wacc - terminal_growth)
|
|
pv += tv / ((1 + wacc) ** years)
|
|
return pv
|
|
|
|
|
|
def dcf_10y_2stage(
|
|
fcf: float,
|
|
wacc: float,
|
|
term_growth: float,
|
|
fcf_growth: float,
|
|
) -> float:
|
|
"""10-year two-stage DCF.
|
|
|
|
Stage 1 (Y1-5): FCF grows at *fcf_growth*.
|
|
Stage 2 (Y6-10): growth linearly fades to *term_growth*.
|
|
Terminal value at Y10 using Gordon Growth.
|
|
"""
|
|
if fcf is None or fcf <= 0:
|
|
return 0.0
|
|
if wacc <= term_growth or wacc <= 0:
|
|
return 0.0
|
|
pv = 0.0
|
|
fcft = float(fcf)
|
|
for t in range(1, 6):
|
|
pv += fcft / ((1 + wacc) ** t)
|
|
fcft *= (1 + fcf_growth)
|
|
for t in range(6, 11):
|
|
fade = (t - 6) / 4.0
|
|
g_t = fcf_growth + fade * (term_growth - fcf_growth)
|
|
fcft *= (1 + g_t)
|
|
pv += fcft / ((1 + wacc) ** t)
|
|
tv = fcft * (1 + term_growth) / (wacc - term_growth)
|
|
pv += tv / ((1 + wacc) ** 10)
|
|
return pv
|
|
|
|
|
|
def excel_style_dcf(
|
|
fcf_base: float,
|
|
wacc: float,
|
|
term_growth: float,
|
|
fcf_growth: float,
|
|
total_debt: float,
|
|
cash: float,
|
|
shares: float,
|
|
) -> Dict[str, Optional[float]]:
|
|
"""Full DCF: EV -> Equity Value -> Value per Share.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Keys: ``ev``, ``equity_value``, ``value_per_share``, ``shares``.
|
|
"""
|
|
ev = dcf_10y_2stage(fcf_base, wacc, term_growth, fcf_growth)
|
|
equity = ev - total_debt + cash
|
|
shares_safe = float(shares) if (shares is not None and float(shares) > 0) else None
|
|
value_per_share = (equity / shares_safe) if shares_safe else None
|
|
return {
|
|
"ev": ev,
|
|
"equity_value": equity,
|
|
"value_per_share": value_per_share,
|
|
"shares": shares_safe,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WACC helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def reverse_dcf(
|
|
current_price: float,
|
|
shares: float,
|
|
total_debt: float,
|
|
cash: float,
|
|
wacc: float,
|
|
term_growth: float,
|
|
fcf_base: float,
|
|
projection_years: int = 10,
|
|
) -> Optional[float]:
|
|
"""Solve for the implied FCF growth rate that produces the current market price.
|
|
|
|
Uses Brent's root-finding method (scipy.optimize.brentq) to find the
|
|
growth rate *g* such that ``excel_style_dcf(..., g)["value_per_share"] == current_price``.
|
|
|
|
Returns
|
|
-------
|
|
float | None
|
|
Implied annual FCF growth rate (decimal), or None if no solution is found.
|
|
"""
|
|
if brentq is None:
|
|
return None
|
|
if shares <= 0 or current_price <= 0 or wacc <= term_growth:
|
|
return None
|
|
|
|
def _objective(g: float) -> float:
|
|
result = excel_style_dcf(fcf_base, wacc, term_growth, g, total_debt, cash, shares)
|
|
vps = result.get("value_per_share")
|
|
if vps is None:
|
|
return -current_price
|
|
return vps - current_price
|
|
|
|
try:
|
|
implied_growth = brentq(_objective, -0.50, 1.00, xtol=1e-6, maxiter=200)
|
|
return round(implied_growth, 6)
|
|
except (ValueError, RuntimeError):
|
|
return None
|
|
|
|
|
|
def _damodaran_wacc_for_sector(sector: str) -> float:
|
|
"""Map a yfinance sector string to closest Damodaran WACC (default 8.0%)."""
|
|
if not sector:
|
|
return 8.0
|
|
s = (sector or "").lower()
|
|
if "software" in s or "technology" in s or "internet" in s:
|
|
return DAMODARAN_WACC.get("Software", 8.5)
|
|
if "hardware" in s or "semiconductor" in s:
|
|
return DAMODARAN_WACC.get("Hardware", 9.0)
|
|
if "retail" in s or "consumer" in s or "cyclical" in s:
|
|
return DAMODARAN_WACC.get("Retail", 7.5)
|
|
if "financial" in s or "bank" in s or "insurance" in s:
|
|
return DAMODARAN_WACC.get("Financials", 8.0)
|
|
if "health" in s or "pharma" in s:
|
|
return DAMODARAN_WACC.get("Healthcare", 7.2)
|
|
if "industrial" in s:
|
|
return DAMODARAN_WACC.get("Industrial", 7.8)
|
|
if "energy" in s or "oil" in s:
|
|
return DAMODARAN_WACC.get("Energy", 8.2)
|
|
if "utilities" in s:
|
|
return DAMODARAN_WACC.get("Utilities", 6.5)
|
|
return 8.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Smart defaults
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_dcf_smart_defaults(ticker: str) -> Dict[str, float]:
|
|
"""Auto-generate WACC, Terminal Growth, and FCF Growth from CAPM beta and analyst estimates.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Keys: ``wacc_pct``, ``term_growth_pct``, ``fcf_growth_pct``.
|
|
"""
|
|
out: Dict[str, float] = {"wacc_pct": 10.0, "term_growth_pct": 2.5, "fcf_growth_pct": 8.0}
|
|
if not yf or not ticker:
|
|
return out
|
|
try:
|
|
t = yf.Ticker(ticker.upper())
|
|
info = t.info or {}
|
|
beta = info.get("beta")
|
|
if beta is None:
|
|
beta = 1.0
|
|
else:
|
|
try:
|
|
beta = float(beta)
|
|
except (TypeError, ValueError):
|
|
beta = 1.0
|
|
risk_free = 4.0
|
|
market_risk_premium = 5.0
|
|
calculated_wacc = risk_free + (beta * market_risk_premium)
|
|
out["wacc_pct"] = round(min(20.0, max(4.0, calculated_wacc)), 1)
|
|
out["term_growth_pct"] = 2.5
|
|
rev_growth = info.get("revenueGrowth") or info.get("earningsGrowth")
|
|
if rev_growth is not None:
|
|
try:
|
|
g = float(rev_growth)
|
|
out["fcf_growth_pct"] = round(min(30.0, max(-10.0, g * 100)), 1)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return out
|
|
except Exception:
|
|
return out
|