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>
This commit is contained in:
shawnkim1997
2026-03-21 02:10:10 +00:00
co-authored by Claude Opus 4.6
parent 56a9561f71
commit b2acda81ee
111 changed files with 13883 additions and 270 deletions
View File
+49
View File
@@ -0,0 +1,49 @@
"""Shared pytest fixtures for ATLAS Terminal test suite."""
import sys
from pathlib import Path
import pytest
# Ensure the project root is on sys.path so `server.*` imports work.
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
@pytest.fixture
def sample_ticker():
"""A well-known US ticker for integration-style tests."""
return "AAPL"
@pytest.fixture
def sample_fcf_inputs():
"""Reasonable DCF inputs for unit-testing valuation functions."""
return {
"fcf": 100_000_000_000, # $100B trailing FCF
"wacc": 0.10, # 10%
"terminal_growth": 0.025, # 2.5%
"fcf_growth": 0.08, # 8%
"total_debt": 110_000_000_000, # $110B
"cash": 60_000_000_000, # $60B
"shares": 15_500_000_000, # 15.5B shares
}
@pytest.fixture
def sample_balance_sheet_values():
"""Simplified balance-sheet figures for Altman Z / DuPont tests."""
return {
"current_assets": 150_000_000_000,
"current_liabilities": 120_000_000_000,
"total_assets": 350_000_000_000,
"retained_earnings": 50_000_000_000,
"total_liabilities": 290_000_000_000,
"total_equity": 60_000_000_000,
"ebit": 120_000_000_000,
"sales": 400_000_000_000,
"market_cap": 2_800_000_000_000,
"net_income": 95_000_000_000,
"revenue": 400_000_000_000,
}
+198
View File
@@ -0,0 +1,198 @@
"""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"]
@@ -0,0 +1,210 @@
"""Tests for server.services.financial_metrics -- DuPont, Altman Z, radar normalisation."""
import pytest
from server.services.financial_metrics import _radar_norm
# ---------------------------------------------------------------------------
# _radar_norm
# ---------------------------------------------------------------------------
class TestRadarNorm:
"""Verify radar chart normalisation to 0-100 range."""
def test_all_none_returns_defaults(self):
result = _radar_norm(None, None, None, None, None)
assert result == [50, 50, 50, 50, 50]
def test_returns_five_values(self):
result = _radar_norm(15.0, 1.5, 1.0, 2.0, 10.0)
assert len(result) == 5
def test_all_values_in_range(self):
result = _radar_norm(30.0, 2.5, 1.5, 2.5, 25.0)
for v in result:
assert 0 <= v <= 100
def test_extreme_high_values_capped_at_100(self):
result = _radar_norm(100.0, 10.0, 5.0, 10.0, 100.0)
for v in result:
assert v <= 100
def test_extreme_low_values_floored_at_0(self):
result = _radar_norm(-50.0, -1.0, -1.0, -1.0, -50.0)
for v in result:
assert v >= 0
def test_roe_normalisation(self):
# n_roe: (x + 10) / 40 * 100
# ROE = 30% -> (30+10)/40*100 = 100
result = _radar_norm(30.0, None, None, None, None)
assert result[0] == 100.0
def test_roe_negative(self):
# ROE = -10% -> (-10+10)/40*100 = 0
result = _radar_norm(-10.0, None, None, None, None)
assert result[0] == 0.0
def test_current_ratio_normalisation(self):
# n_cr: x / 3 * 100
# CR = 1.5 -> 1.5/3*100 = 50
result = _radar_norm(None, 1.5, None, None, None)
assert result[1] == 50.0
def test_asset_turnover_normalisation(self):
# n_at: x * 50
# AT = 1.0 -> 50
result = _radar_norm(None, None, 1.0, None, None)
assert result[2] == 50.0
def test_equity_mult_normalisation(self):
# n_em: (x - 0.5) / 2.5 * 100
# EM = 2.0 -> (2.0-0.5)/2.5*100 = 60
result = _radar_norm(None, None, None, 2.0, None)
assert result[3] == 60.0
def test_yoy_normalisation(self):
# n_yoy: (x + 20) / 50 * 100
# YoY = 10% -> (10+20)/50*100 = 60
result = _radar_norm(None, None, None, None, 10.0)
assert result[4] == 60.0
# ---------------------------------------------------------------------------
# Altman Z-Score formula verification (unit-level)
# ---------------------------------------------------------------------------
class TestAltmanZFormula:
"""Verify the Altman Z-Score formula independently of data fetching."""
def test_altman_z_manual_calculation(self, sample_balance_sheet_values):
"""Hand-compute Altman Z and check the formula:
Z = 1.2*A + 1.4*B + 3.3*C + 0.6*D + 1.0*E
where:
A = Working Capital / Total Assets
B = Retained Earnings / Total Assets
C = EBIT / Total Assets
D = Market Cap / Total Liabilities
E = Sales / Total Assets
"""
v = sample_balance_sheet_values
ta = v["total_assets"]
a = (v["current_assets"] - v["current_liabilities"]) / ta
b = v["retained_earnings"] / ta
c = v["ebit"] / ta
d = v["market_cap"] / v["total_liabilities"]
e = v["sales"] / ta
z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e
# With the sample values:
# A = (150B-120B)/350B = 30/350 = 0.08571
# B = 50B/350B = 0.14286
# C = 120B/350B = 0.34286
# D = 2800B/290B = 9.65517
# E = 400B/350B = 1.14286
assert z == pytest.approx(
1.2 * 0.08571 + 1.4 * 0.14286 + 3.3 * 0.34286 + 0.6 * 9.65517 + 1.0 * 1.14286,
rel=0.01,
)
# Z > 2.99 is "safe zone"
assert z > 2.99
def test_altman_z_distress_zone(self):
"""A company with poor financials should score below 1.81."""
ta = 100
a = -20 / ta # negative working capital
b = -10 / ta # negative retained earnings
c = -5 / ta # negative EBIT (loss)
d = 10 / 90 # low market cap vs liabilities
e = 50 / ta # low sales/assets
z = 1.2 * a + 1.4 * b + 3.3 * c + 0.6 * d + 1.0 * e
assert z < 1.81
# ---------------------------------------------------------------------------
# DuPont 3-step formula verification (unit-level)
# ---------------------------------------------------------------------------
class TestDuPontFormula:
"""Verify DuPont decomposition: ROE = NPM * Asset Turnover * Equity Multiplier."""
def test_dupont_identity(self, sample_balance_sheet_values):
v = sample_balance_sheet_values
npm = v["net_income"] / v["revenue"] # Net Profit Margin
asset_turnover = v["revenue"] / v["total_assets"] # Asset Turnover
equity_mult = v["total_assets"] / v["total_equity"] # Equity Multiplier
roe_dupont = npm * asset_turnover * equity_mult
roe_direct = v["net_income"] / v["total_equity"]
assert roe_dupont == pytest.approx(roe_direct, rel=1e-9)
def test_dupont_components_reasonable(self, sample_balance_sheet_values):
v = sample_balance_sheet_values
npm = v["net_income"] / v["revenue"]
at = v["revenue"] / v["total_assets"]
em = v["total_assets"] / v["total_equity"]
assert 0 < npm < 1 # Profit margin should be between 0% and 100%
assert at > 0 # Asset turnover should be positive
assert em >= 1 # Equity multiplier is always >= 1 for solvent firms
# ---------------------------------------------------------------------------
# Piotroski F-Score criteria (unit-level check)
# ---------------------------------------------------------------------------
class TestPiotroskiFScoreCriteria:
"""Verify individual F-Score criteria logic."""
def test_positive_net_income_scores_1(self):
assert (1 if 95_000_000_000 > 0 else 0) == 1
def test_negative_net_income_scores_0(self):
assert (1 if -5_000_000 > 0 else 0) == 0
def test_positive_roa_change_scores_1(self):
roa_curr = 0.27
roa_prev = 0.25
assert (1 if roa_curr > roa_prev else 0) == 1
def test_positive_ocf_scores_1(self):
assert (1 if 100_000_000_000 > 0 else 0) == 1
def test_ocf_gt_net_income_scores_1(self):
ocf = 120_000_000_000
ni = 95_000_000_000
assert (1 if ocf > ni else 0) == 1
def test_leverage_decrease_scores_1(self):
debt_to_assets_curr = 0.40
debt_to_assets_prev = 0.45
assert (1 if debt_to_assets_curr < debt_to_assets_prev else 0) == 1
def test_current_ratio_increase_scores_1(self):
cr_curr = 1.35
cr_prev = 1.20
assert (1 if cr_curr > cr_prev else 0) == 1
def test_no_dilution_scores_1(self):
shares_curr = 15_500_000_000
shares_prev = 15_800_000_000
assert (1 if shares_curr <= shares_prev else 0) == 1
def test_gross_margin_increase_scores_1(self):
gm_curr = 0.45
gm_prev = 0.43
assert (1 if gm_curr > gm_prev else 0) == 1
def test_asset_turnover_increase_scores_1(self):
at_curr = 1.15
at_prev = 1.10
assert (1 if at_curr > at_prev else 0) == 1
def test_max_fscore_is_9(self):
"""All 9 criteria passing should sum to 9."""
criteria = [1, 1, 1, 1, 1, 1, 1, 1, 1]
assert sum(criteria) == 9
+143
View File
@@ -0,0 +1,143 @@
"""Tests for server.utils.safe_float -- edge cases and boundary conditions."""
import math
import pandas as pd
import pytest
from server.utils.safe_float import _safe_float, _na, _format_shares_display
# ---------------------------------------------------------------------------
# _safe_float
# ---------------------------------------------------------------------------
class TestSafeFloat:
"""Exhaustive edge-case coverage for _safe_float."""
def test_none_returns_none(self):
assert _safe_float(None) is None
def test_nan_float_returns_none(self):
assert _safe_float(float("nan")) is None
def test_math_nan_returns_none(self):
assert _safe_float(math.nan) is None
def test_pandas_na_returns_none(self):
assert _safe_float(pd.NA) is None
def test_pandas_nat_returns_none(self):
assert _safe_float(pd.NaT) is None
def test_int_converts(self):
assert _safe_float(42) == 42.0
def test_float_passthrough(self):
assert _safe_float(3.14) == 3.14
def test_negative_float(self):
assert _safe_float(-9.99) == -9.99
def test_zero(self):
assert _safe_float(0) == 0.0
def test_string_numeric(self):
assert _safe_float("123.45") == 123.45
def test_string_negative(self):
assert _safe_float("-7.5") == -7.5
def test_string_non_numeric_returns_none(self):
assert _safe_float("hello") is None
def test_empty_string_returns_none(self):
assert _safe_float("") is None
def test_bool_true(self):
# bool is subclass of int; float(True) == 1.0
assert _safe_float(True) == 1.0
def test_bool_false(self):
assert _safe_float(False) == 0.0
def test_inf_positive(self):
result = _safe_float(float("inf"))
assert result == float("inf")
def test_inf_negative(self):
result = _safe_float(float("-inf"))
assert result == float("-inf")
def test_large_number(self):
assert _safe_float(1e18) == 1e18
def test_very_small_number(self):
assert _safe_float(1e-15) == pytest.approx(1e-15)
def test_object_returns_none(self):
assert _safe_float(object()) is None
def test_list_returns_none(self):
assert _safe_float([1, 2, 3]) is None
def test_dict_returns_none(self):
assert _safe_float({"a": 1}) is None
# ---------------------------------------------------------------------------
# _na
# ---------------------------------------------------------------------------
class TestNa:
"""Tests for the _na display helper."""
def test_none_returns_na_string(self):
assert _na(None) == "N/A"
def test_nan_returns_na_string(self):
assert _na(float("nan")) == "N/A"
def test_valid_float_passthrough(self):
assert _na(3.14) == 3.14
def test_zero_passthrough(self):
assert _na(0) == 0
def test_string_passthrough(self):
assert _na("hello") == "hello"
# ---------------------------------------------------------------------------
# _format_shares_display
# ---------------------------------------------------------------------------
class TestFormatSharesDisplay:
"""Tests for the _format_shares_display utility."""
def test_none_returns_na(self):
assert _format_shares_display(None) == "N/A"
def test_zero_returns_na(self):
assert _format_shares_display(0) == "N/A"
def test_negative_returns_na(self):
assert _format_shares_display(-100) == "N/A"
def test_billions(self):
assert _format_shares_display(15_420_000_000) == "15.42B Shares"
def test_millions(self):
assert _format_shares_display(1_200_000) == "1.20M Shares"
def test_thousands(self):
assert _format_shares_display(5_500) == "5.50K Shares"
def test_small_number(self):
assert _format_shares_display(42) == "42 Shares"
def test_exact_billion(self):
assert _format_shares_display(1_000_000_000) == "1.00B Shares"
def test_exact_million(self):
assert _format_shares_display(1_000_000) == "1.00M Shares"