summaryrefslogtreecommitdiff
path: root/backend
diff options
context:
space:
mode:
Diffstat (limited to 'backend')
-rw-r--r--backend/app/services/data_service.py48
-rw-r--r--backend/tests/test_api.py96
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())