From 38bef98ab1964bc9365d17c4a5ca17cdbd32673b Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 18 May 2026 02:34:46 -0700 Subject: fix: correct historical ratios share and debt inputs --- backend/app/services/data_service.py | 48 ++++++++++++++++-- backend/tests/test_api.py | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 220accc..e356cb4 100644 --- a/backend/app/services/data_service.py +++ b/backend/app/services/data_service.py @@ -645,8 +645,17 @@ def compute_historical_ratios(symbol: str) -> dict[str, list[float | None]]: return {} years = list(inc_a.columns[: min(len(inc_a.columns), 4)]) - shares = get_shares_outstanding(sym) price_history = get_price_history(sym, period="5y") + current_shares = get_shares_outstanding(sym) + + try: + shares_history_raw = yf.Ticker(sym).get_shares_full(start="2000-01-01") + if isinstance(shares_history_raw, pd.Series): + shares_history = pd.to_numeric(shares_history_raw, errors="coerce").dropna().sort_index() + else: + shares_history = pd.Series(dtype=float) + except Exception: + shares_history = pd.Series(dtype=float) result: dict[str, list[float | None]] = {k: [] for k in [ "gross_margin", "operating_margin", "net_margin", "ebitda_margin", @@ -654,6 +663,38 @@ def compute_historical_ratios(symbol: str) -> dict[str, list[float | None]]: "trailing_pe", "ev_to_ebitda", "price_to_book", "price_to_sales", ]} + def _balance_shares(period_date: pd.Timestamp) -> float | None: + if bal_a is None or bal_a.empty or period_date not in bal_a.columns: + return None + for label in _SHARE_LABELS: + if label not in bal_a.index: + continue + shares_value = _safe_float(bal_a.loc[label, period_date]) + if shares_value is not None and shares_value > 0: + return shares_value + return None + + def _historical_shares_for_date(period_date: pd.Timestamp) -> float | None: + direct_balance_shares = _balance_shares(period_date) + if direct_balance_shares is not None: + return direct_balance_shares + if not shares_history.empty: + target = pd.Timestamp(period_date) + index = shares_history.index + if getattr(index, "tz", None) is not None and target.tzinfo is None: + target = target.tz_localize(index.tz) + elif getattr(index, "tz", None) is None and target.tzinfo is not None: + target = target.tz_localize(None) + + deltas = pd.Series(index - target, index=index).abs() + if not deltas.empty: + nearest_idx = deltas.idxmin() + if abs(pd.Timestamp(nearest_idx) - target) <= pd.Timedelta(days=180): + shares_value = _safe_float(shares_history.loc[nearest_idx]) + if shares_value is not None and shares_value > 0: + return shares_value + return current_shares + for col in years: col_dt = pd.Timestamp(col) @@ -674,10 +715,11 @@ def compute_historical_ratios(symbol: str) -> dict[str, list[float | None]]: ebitda = _inc("EBITDA") or _inc("Normalized EBITDA") equity = _bal("Stockholders Equity") or _bal("Common Stock Equity") total_assets = _bal("Total Assets") - total_debt = _bal("Total Debt") + total_debt = _bal("Total Debt") or _bal("Long Term Debt And Capital Lease Obligation") current_assets = _bal("Current Assets") current_liabilities = _bal("Current Liabilities") cash = _bal("Cash And Cash Equivalents") or _bal("Cash Cash Equivalents And Short Term Investments") or 0.0 + period_shares = _historical_shares_for_date(col_dt) rev = revenue if revenue and revenue > 0 else None result["gross_margin"].append(_cap_ratio(gross_profit / rev, -5, 5) if rev and gross_profit is not None else None) @@ -690,7 +732,7 @@ def compute_historical_ratios(symbol: str) -> dict[str, list[float | None]]: result["current_ratio"].append(current_assets / current_liabilities if current_liabilities and current_liabilities > 0 and current_assets is not None else None) price = _find_price_at_date(price_history, col_dt) - market_cap = price * shares if price and shares else None + market_cap = price * period_shares if price and period_shares else None ev = market_cap + (total_debt or 0.0) - cash if market_cap else None result["trailing_pe"].append(_cap_ratio(market_cap / net_income, 0, 500) if market_cap and net_income and net_income > 0 else None) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 94c9c20..d0aafa4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -993,6 +993,102 @@ def test_compute_historical_ratios_margins(monkeypatch) -> 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()) -- cgit v1.3-2-g0d8e