Files
All-in-one-Financial-Analysis/atlas-terminal/tests/test_dcf_engine.py
T
shawnkim1997andClaude Opus 4.6 b2acda81ee feat: add Atlas Terminal — Next.js 14 + FastAPI full-stack migration
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>
2026-03-21 02:10:10 +00:00

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"]