summaryrefslogtreecommitdiff
path: root/backend/tests
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:45:59 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:45:59 -0700
commit66cfb26ebd8fa44b24e37b4ffc796ab29dcbd704 (patch)
tree4d98b268502c6aa7c8988957d6e41dffd319534d /backend/tests
parent7fc2f0177518d70114aa75b7874a0ef59bdaec61 (diff)
parent52635efd7d435b091b4f13897511ca8e2c48f0b9 (diff)
Merge branch 'feat/key-ratios-tab'
Diffstat (limited to 'backend/tests')
-rw-r--r--backend/tests/test_api.py340
1 files changed, 340 insertions, 0 deletions
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index af43975..345c0a3 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -19,6 +19,9 @@ def clear_service_caches() -> None:
data_service.RATIO_CACHE.clear()
data_service.FINANCIALS_CACHE.clear()
data_service.VALUATION_CACHE.clear()
+ data_service.HIST_RATIOS_CACHE.clear()
+ data_service.RATIOS_ENDPOINT_CACHE.clear()
+ data_service.SECTOR_BENCHMARK_CACHE.clear()
def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame:
@@ -940,3 +943,340 @@ def test_valuation_route_returns_structure(monkeypatch) -> None:
assert result["dcf"]["intrinsic_value_per_share"] == 182.0
assert result["ev_ebitda"]["multiple_used"] == 20.0
assert result["ev_revenue"]["available"] is False
+
+
+def test_compute_historical_ratios_margins(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Total Revenue": [100.0, 90.0, 80.0, 70.0],
+ "Gross Profit": [50.0, 45.0, 40.0, 35.0],
+ "Operating Income": [20.0, 18.0, 16.0, 14.0],
+ "Net Income": [10.0, 9.0, 8.0, 7.0],
+ "EBITDA": [25.0, 22.5, 20.0, 17.5],
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "get_balance_sheet",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Stockholders Equity": [50.0, 45.0, 40.0, 35.0],
+ "Total Assets": [100.0, 90.0, 80.0, 70.0],
+ "Total Debt": [20.0, 18.0, 16.0, 14.0],
+ "Current Assets": [30.0, 27.0, 24.0, 21.0],
+ "Current Liabilities": [15.0, 13.5, 12.0, 10.5],
+ "Cash And Cash Equivalents": [5.0, 4.5, 4.0, 3.5],
+ }
+ ),
+ )
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: annual_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]}))
+ monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 1_000.0)
+ monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": [])
+
+ result = data_service.compute_historical_ratios("AAPL")
+
+ assert result["gross_margin"] == [0.5, 0.5, 0.5, 0.5]
+ assert result["operating_margin"] == [0.2, 0.2, 0.2, 0.2]
+ assert result["net_margin"] == [0.1, 0.1, 0.1, 0.1]
+ assert result["ebitda_margin"] == [0.25, 0.25, 0.25, 0.25]
+ assert result["roe"] == [0.2, 0.2, 0.2, 0.2]
+ assert result["roa"] == [0.1, 0.1, 0.1, 0.1]
+ assert result["debt_to_equity"] == [0.4, 0.4, 0.4, 0.4]
+ assert result["current_ratio"] == [2.0, 2.0, 2.0, 2.0]
+ assert result["trailing_pe"] == [None, None, None, None]
+ assert result["ev_to_ebitda"] == [None, None, None, None]
+ assert result["price_to_book"] == [None, None, None, None]
+ assert result["price_to_sales"] == [None, None, None, None]
+
+
+def test_compute_historical_ratios_uses_period_share_counts_oldest_first(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Total Revenue": [40.0, 40.0, 40.0, 40.0],
+ "Gross Profit": [20.0, 20.0, 20.0, 20.0],
+ "Operating Income": [15.0, 15.0, 15.0, 15.0],
+ "Net Income": [10.0, 10.0, 10.0, 10.0],
+ "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "get_balance_sheet",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Stockholders Equity": [20.0, 20.0, 20.0, 20.0],
+ "Total Assets": [50.0, 50.0, 50.0, 50.0],
+ "Total Debt": [100_000.0, 100_000.0, 100_000.0, 100_000.0],
+ "Current Assets": [10.0, 10.0, 10.0, 10.0],
+ "Current Liabilities": [5.0, 5.0, 5.0, 5.0],
+ "Cash And Cash Equivalents": [0.0, 0.0, 0.0, 0.0],
+ "Ordinary Shares Number": [10.0, 20.0, 40.0, 80.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 999.0)
+ monkeypatch.setattr(
+ data_service,
+ "get_price_history",
+ lambda symbol, period="5y": [
+ {"date": "2024-09-30", "close": 10.0},
+ {"date": "2023-09-30", "close": 10.0},
+ {"date": "2022-09-30", "close": 10.0},
+ {"date": "2021-09-30", "close": 10.0},
+ ],
+ )
+
+ result = data_service.compute_historical_ratios("AAPL")
+
+ assert result["trailing_pe"] == [80.0, 40.0, 20.0, 10.0]
+ assert result["price_to_book"] == [40.0, 20.0, 10.0, 5.0]
+ assert result["price_to_sales"] == [20.0, 10.0, 5.0, 2.5]
+
+
+def test_compute_historical_ratios_uses_long_term_debt_fallback(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Total Revenue": [5_000_000.0, 5_000_000.0, 5_000_000.0, 5_000_000.0],
+ "Gross Profit": [2_500_000.0, 2_500_000.0, 2_500_000.0, 2_500_000.0],
+ "Operating Income": [2_100_000.0, 2_100_000.0, 2_100_000.0, 2_100_000.0],
+ "Net Income": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0],
+ "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "get_balance_sheet",
+ lambda symbol, quarterly=False: annual_frame(
+ {
+ "Stockholders Equity": [4_000_000.0, 4_000_000.0, 4_000_000.0, 4_000_000.0],
+ "Total Assets": [8_000_000.0, 8_000_000.0, 8_000_000.0, 8_000_000.0],
+ "Long Term Debt And Capital Lease Obligation": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0],
+ "Cash And Cash Equivalents": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0],
+ "Ordinary Shares Number": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None)
+ monkeypatch.setattr(
+ data_service,
+ "get_price_history",
+ lambda symbol, period="5y": [
+ {"date": "2024-09-30", "close": 10.0},
+ {"date": "2023-09-30", "close": 10.0},
+ {"date": "2022-09-30", "close": 10.0},
+ {"date": "2021-09-30", "close": 10.0},
+ ],
+ )
+
+ result = data_service.compute_historical_ratios("AAPL")
+
+ assert result["ev_to_ebitda"] == [5.5, 5.5, 5.5, 5.5]
+
+
+def test_compute_historical_ratios_empty_income(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame())
+ monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None)
+ monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": [])
+
+ result = data_service.compute_historical_ratios("AAPL")
+
+ assert result == {}
+
+
+def test_get_ratios_quick_ratio(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0, "current_ratio": 2.0})
+ monkeypatch.setattr(
+ data_service,
+ "compute_historical_ratios",
+ lambda symbol: {
+ "current_ratio": [1.4, 1.5, 1.6, 1.8],
+ },
+ )
+ monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {})
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=True: quarterly_frame(
+ {
+ "Total Revenue": [100.0, 100.0, 100.0, 100.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "get_balance_sheet",
+ lambda symbol, quarterly=True: quarterly_frame(
+ {
+ "Current Assets": [200.0, 0.0, 0.0, 0.0],
+ "Current Liabilities": [100.0, 0.0, 0.0, 0.0],
+ "Inventory": [30.0, 0.0, 0.0, 0.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]}))
+ monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": [])
+
+ result = data_service.get_ratios("AAPL")
+
+ assert result["quick_ratio"]["value"] == 1.7
+ assert result["quick_ratio"]["spark"] == []
+ assert result["current_ratio"]["value"] == 2.0
+ assert result["current_ratio"]["spark"] == [1.4, 1.5, 1.6, 1.8]
+
+
+def test_get_ratios_interest_coverage(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0})
+ monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {})
+ monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {})
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=True: quarterly_frame(
+ {
+ "Total Revenue": [100.0, 100.0, 100.0, 100.0],
+ "EBIT": [42.5, 42.5, 42.5, 42.5],
+ "Interest Expense": [5.0, 5.0, 5.0, 5.0],
+ }
+ ),
+ )
+ monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=True: quarterly_frame({}))
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]}))
+ monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": [])
+
+ result = data_service.get_ratios("AAPL")
+
+ assert result["interest_coverage"]["value"] == 8.5
+ assert result["interest_coverage"]["spark"] == []
+
+
+def test_get_ratios_sector_benchmark_fields(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.setattr(
+ data_service,
+ "compute_ttm_ratios",
+ lambda symbol: {
+ "market_cap": 1000.0,
+ "trailing_pe": 20.0,
+ "price_to_book": 5.0,
+ "current_ratio": 2.0,
+ "dividend_yield_ttm": 0.01,
+ },
+ )
+ monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {})
+ monkeypatch.setattr(
+ data_service,
+ "compute_sector_ratio_benchmarks",
+ lambda symbol: {
+ "trailing_pe": 18.0,
+ "price_to_book": 4.0,
+ "current_ratio": 1.7,
+ "dividend_yield_ttm": 0.012,
+ },
+ )
+ monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {})
+ monkeypatch.setattr(
+ data_service,
+ "get_income_statement",
+ lambda symbol, quarterly=True: quarterly_frame({"Total Revenue": [100.0, 100.0, 100.0, 100.0]}),
+ )
+ monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=True: quarterly_frame({}))
+ monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({}))
+
+ result = data_service.get_ratios("AAPL")
+
+ assert result["pe_ttm"]["vs_sector"] == 18.0
+ assert result["price_to_book"]["vs_sector"] == 4.0
+ assert result["current_ratio"]["vs_sector"] == 1.7
+ assert result["dividend_yield"]["vs_sector"] == pytest.approx(0.012)
+
+
+def test_compute_sector_ratio_benchmarks_without_fmp_key(monkeypatch) -> None:
+ clear_service_caches()
+ monkeypatch.delenv("FMP_API_KEY", raising=False)
+ monkeypatch.setattr(
+ data_service,
+ "get_company_info",
+ lambda symbol: (
+ {"sector": "Technology"}
+ if symbol == "AAPL"
+ else {"sector": "Technology"}
+ if symbol == "MSFT"
+ else {"sector": "Technology"}
+ if symbol == "NVDA"
+ else {"sector": "Healthcare"}
+ ),
+ )
+ monkeypatch.setattr(
+ data_service,
+ "search_tickers",
+ lambda query: [
+ {"symbol": "MSFT", "name": "Microsoft", "exchange": "NASDAQ"},
+ {"symbol": "NVDA", "name": "NVIDIA", "exchange": "NASDAQ"},
+ {"symbol": "UNH", "name": "UnitedHealth", "exchange": "NYSE"},
+ ],
+ )
+ monkeypatch.setattr(
+ data_service,
+ "compute_ttm_ratios",
+ lambda symbol: (
+ {"trailing_pe": 30.0, "current_ratio": 2.0}
+ if symbol == "MSFT"
+ else {"trailing_pe": 20.0, "current_ratio": 1.5}
+ if symbol == "NVDA"
+ else {"trailing_pe": 10.0, "current_ratio": 1.0}
+ ),
+ )
+
+ result = data_service.compute_sector_ratio_benchmarks("AAPL")
+
+ assert result["trailing_pe"] == pytest.approx(25.0)
+ assert result["current_ratio"] == pytest.approx(1.75)
+
+
+def test_ticker_ratios_route(monkeypatch) -> None:
+ """GET /api/tickers/{symbol}/ratios returns a valid RatiosResponse shape."""
+ clear_service_caches()
+
+ def _fake_ratios(sym: str) -> dict:
+ def _pt(v=None):
+ return {"value": v, "spark": [], "vs_sector": None}
+
+ return {
+ "pe_ttm": _pt(24.3), "ev_ebitda": _pt(16.1),
+ "gross_margin": _pt(0.46), "net_margin": _pt(0.14),
+ "price_to_book": _pt(5.8), "price_to_sales": _pt(6.2),
+ "ev_to_sales": _pt(6.5), "p_fcf": _pt(28.4), "forward_pe": _pt(22.0),
+ "operating_margin": _pt(0.19), "ebitda_margin": _pt(0.22), "fcf_margin": _pt(0.18),
+ "roe": _pt(0.38), "roa": _pt(0.12), "roic": _pt(0.22),
+ "debt_to_equity": _pt(1.4), "current_ratio": _pt(1.9),
+ "quick_ratio": _pt(1.5), "interest_coverage": _pt(8.5),
+ "dividend_yield": _pt(None), "dividend_payout": _pt(None),
+ }
+
+ monkeypatch.setattr(main.data_service, "get_ratios", _fake_ratios)
+ result = main.ticker_ratios("AAPL")
+ assert result["pe_ttm"]["value"] == pytest.approx(24.3)
+ assert result["gross_margin"]["spark"] == []
+ assert result["dividend_yield"]["vs_sector"] is None