mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-25 16:28:04 +00:00
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:
co-authored by
Claude Opus 4.6
parent
56a9561f71
commit
b2acda81ee
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user