mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-15 11:28:08 +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>
199 lines
7.8 KiB
Python
199 lines
7.8 KiB
Python
"""Tests for server.services.dcf_engine -- DCF formula accuracy."""
|
|
|
|
import pytest
|
|
|
|
from server.services.dcf_engine import (
|
|
dcf_intrinsic_value,
|
|
dcf_10y_2stage,
|
|
excel_style_dcf,
|
|
_damodaran_wacc_for_sector,
|
|
DAMODARAN_WACC,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dcf_intrinsic_value (5-year single-stage)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDCFIntrinsicValue:
|
|
"""Verify 5-year single-stage DCF maths."""
|
|
|
|
def test_basic_positive_fcf(self):
|
|
"""Known-good manual calculation with simple inputs."""
|
|
result = dcf_intrinsic_value(
|
|
fcf=100, wacc=0.10, terminal_growth=0.02, fcf_growth=0.05, years=5,
|
|
)
|
|
# Manually:
|
|
# Y1: 100/(1.10)^1, Y2: 105/(1.10)^2, ... + terminal value
|
|
assert result > 0
|
|
# Rough sanity: terminal value dominates, so EV > 5 * FCF
|
|
assert result > 500
|
|
|
|
def test_zero_fcf_returns_zero(self):
|
|
assert dcf_intrinsic_value(0, 0.10, 0.02, 0.05) == 0.0
|
|
|
|
def test_negative_fcf_returns_zero(self):
|
|
assert dcf_intrinsic_value(-100, 0.10, 0.02, 0.05) == 0.0
|
|
|
|
def test_none_fcf_returns_zero(self):
|
|
assert dcf_intrinsic_value(None, 0.10, 0.02, 0.05) == 0.0
|
|
|
|
def test_wacc_less_than_terminal_growth_returns_zero(self):
|
|
"""Gordon growth model breaks if WACC <= g."""
|
|
assert dcf_intrinsic_value(100, 0.02, 0.05, 0.05) == 0.0
|
|
|
|
def test_wacc_equal_terminal_growth_returns_zero(self):
|
|
assert dcf_intrinsic_value(100, 0.05, 0.05, 0.05) == 0.0
|
|
|
|
def test_zero_wacc_returns_zero(self):
|
|
assert dcf_intrinsic_value(100, 0, 0.02, 0.05) == 0.0
|
|
|
|
def test_higher_growth_higher_value(self):
|
|
"""Increasing FCF growth should increase EV."""
|
|
low = dcf_intrinsic_value(100, 0.10, 0.02, 0.03)
|
|
high = dcf_intrinsic_value(100, 0.10, 0.02, 0.10)
|
|
assert high > low
|
|
|
|
def test_higher_wacc_lower_value(self):
|
|
"""Increasing WACC should decrease EV (more discounting)."""
|
|
low_wacc = dcf_intrinsic_value(100, 0.08, 0.02, 0.05)
|
|
high_wacc = dcf_intrinsic_value(100, 0.15, 0.02, 0.05)
|
|
assert low_wacc > high_wacc
|
|
|
|
def test_reproducibility(self):
|
|
"""Same inputs always yield same result (deterministic)."""
|
|
a = dcf_intrinsic_value(1000, 0.10, 0.025, 0.08, years=5)
|
|
b = dcf_intrinsic_value(1000, 0.10, 0.025, 0.08, years=5)
|
|
assert a == b
|
|
|
|
def test_manual_calculation(self):
|
|
"""Hand-verify a simple 2-year DCF with no growth."""
|
|
# FCF=100, growth=0%, WACC=10%, terminal_growth=0%, years=2
|
|
# Y1 PV = 100/1.10 = 90.909...
|
|
# Y2 PV = 100/1.21 = 82.644...
|
|
# Terminal FCF after Y2 = 100 (no growth applied beyond projection)
|
|
# TV = 100*(1+0)/(0.10-0) = 1000
|
|
# PV of TV = 1000/1.21 = 826.446...
|
|
# Total = 90.909 + 82.644 + 826.446 = ~1000
|
|
result = dcf_intrinsic_value(100, 0.10, 0.0, 0.0, years=2)
|
|
assert result == pytest.approx(1000.0, rel=0.01)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dcf_10y_2stage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDCF10y2Stage:
|
|
"""Verify 10-year two-stage DCF."""
|
|
|
|
def test_positive_result(self):
|
|
result = dcf_10y_2stage(fcf=100, wacc=0.10, term_growth=0.02, fcf_growth=0.08)
|
|
assert result > 0
|
|
|
|
def test_zero_fcf(self):
|
|
assert dcf_10y_2stage(0, 0.10, 0.02, 0.08) == 0.0
|
|
|
|
def test_none_fcf(self):
|
|
assert dcf_10y_2stage(None, 0.10, 0.02, 0.08) == 0.0
|
|
|
|
def test_wacc_leq_terminal(self):
|
|
assert dcf_10y_2stage(100, 0.02, 0.03, 0.08) == 0.0
|
|
|
|
def test_two_stage_higher_than_single_with_high_growth(self):
|
|
"""With high near-term growth, 10y 2-stage should capture more value
|
|
than a 5-year model because it has more high-growth years."""
|
|
two_stage = dcf_10y_2stage(100, 0.10, 0.02, 0.15)
|
|
single = dcf_intrinsic_value(100, 0.10, 0.02, 0.15, years=5)
|
|
# 10y model projects more years of above-terminal growth
|
|
assert two_stage > single * 0.8 # at least in the same ballpark
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# excel_style_dcf
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestExcelStyleDCF:
|
|
"""Verify EV -> Equity -> per-share bridge."""
|
|
|
|
def test_basic_output_keys(self, sample_fcf_inputs):
|
|
result = excel_style_dcf(
|
|
fcf_base=sample_fcf_inputs["fcf"],
|
|
wacc=sample_fcf_inputs["wacc"],
|
|
term_growth=sample_fcf_inputs["terminal_growth"],
|
|
fcf_growth=sample_fcf_inputs["fcf_growth"],
|
|
total_debt=sample_fcf_inputs["total_debt"],
|
|
cash=sample_fcf_inputs["cash"],
|
|
shares=sample_fcf_inputs["shares"],
|
|
)
|
|
assert "ev" in result
|
|
assert "equity_value" in result
|
|
assert "value_per_share" in result
|
|
assert "shares" in result
|
|
|
|
def test_equity_equals_ev_minus_debt_plus_cash(self, sample_fcf_inputs):
|
|
result = excel_style_dcf(
|
|
fcf_base=sample_fcf_inputs["fcf"],
|
|
wacc=sample_fcf_inputs["wacc"],
|
|
term_growth=sample_fcf_inputs["terminal_growth"],
|
|
fcf_growth=sample_fcf_inputs["fcf_growth"],
|
|
total_debt=sample_fcf_inputs["total_debt"],
|
|
cash=sample_fcf_inputs["cash"],
|
|
shares=sample_fcf_inputs["shares"],
|
|
)
|
|
expected_equity = result["ev"] - sample_fcf_inputs["total_debt"] + sample_fcf_inputs["cash"]
|
|
assert result["equity_value"] == pytest.approx(expected_equity, rel=1e-9)
|
|
|
|
def test_value_per_share_equals_equity_div_shares(self, sample_fcf_inputs):
|
|
result = excel_style_dcf(
|
|
fcf_base=sample_fcf_inputs["fcf"],
|
|
wacc=sample_fcf_inputs["wacc"],
|
|
term_growth=sample_fcf_inputs["terminal_growth"],
|
|
fcf_growth=sample_fcf_inputs["fcf_growth"],
|
|
total_debt=sample_fcf_inputs["total_debt"],
|
|
cash=sample_fcf_inputs["cash"],
|
|
shares=sample_fcf_inputs["shares"],
|
|
)
|
|
expected_vps = result["equity_value"] / sample_fcf_inputs["shares"]
|
|
assert result["value_per_share"] == pytest.approx(expected_vps, rel=1e-9)
|
|
|
|
def test_zero_shares_returns_none_vps(self):
|
|
result = excel_style_dcf(100, 0.10, 0.02, 0.08, 50, 20, 0)
|
|
assert result["value_per_share"] is None
|
|
|
|
def test_none_shares_returns_none_vps(self):
|
|
result = excel_style_dcf(100, 0.10, 0.02, 0.08, 50, 20, None)
|
|
assert result["value_per_share"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _damodaran_wacc_for_sector
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDamodaranWACC:
|
|
"""Test sector -> WACC mapping."""
|
|
|
|
def test_software_sector(self):
|
|
assert _damodaran_wacc_for_sector("Software") == DAMODARAN_WACC["Software"]
|
|
|
|
def test_technology_sector(self):
|
|
assert _damodaran_wacc_for_sector("Technology") == DAMODARAN_WACC["Software"]
|
|
|
|
def test_healthcare(self):
|
|
assert _damodaran_wacc_for_sector("Healthcare") == DAMODARAN_WACC["Healthcare"]
|
|
|
|
def test_utilities(self):
|
|
assert _damodaran_wacc_for_sector("Utilities") == DAMODARAN_WACC["Utilities"]
|
|
|
|
def test_unknown_sector_default(self):
|
|
assert _damodaran_wacc_for_sector("Alien Technology") == 8.0
|
|
|
|
def test_empty_string_default(self):
|
|
assert _damodaran_wacc_for_sector("") == 8.0
|
|
|
|
def test_none_default(self):
|
|
assert _damodaran_wacc_for_sector(None) == 8.0
|
|
|
|
def test_case_insensitive(self):
|
|
assert _damodaran_wacc_for_sector("software") == DAMODARAN_WACC["Software"]
|
|
assert _damodaran_wacc_for_sector("FINANCIAL SERVICES") == DAMODARAN_WACC["Financials"]
|