summaryrefslogtreecommitdiff
path: root/backend/app/services/data_service.py
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 00:10:00 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 00:10:00 -0700
commit1df80dc1f868b906c5540b66a211947c6886484d (patch)
tree5f1a71ef6259c0f6892a09e48198033e8062ca6e /backend/app/services/data_service.py
parentd3d91f6faca104dcb98487adc0c5ff5d268ed8f7 (diff)
feat: add get_financials() with income/balance/cashflow builders
Implements _build_income, _build_balance, _build_cash_flow, and get_financials() (cached) in data_service.py. Annual mode appends TTM (income/CF) and MRQ (balance) columns; quarterly mode returns up to 8 periods. Adds annual_frame helper and 5 TDD tests covering column labels, TTM sums, MRQ values, FCF computation, and empty-statement graceful returns. Test count: 19 → 24. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'backend/app/services/data_service.py')
-rw-r--r--backend/app/services/data_service.py167
1 files changed, 167 insertions, 0 deletions
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py
index 16e062e..bf75bc7 100644
--- a/backend/app/services/data_service.py
+++ b/backend/app/services/data_service.py
@@ -113,6 +113,173 @@ def _safe_ratio(num: float | None, den: float | None) -> float | None:
return num / den
+def _build_income(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict:
+ if frame is None or frame.empty:
+ return {"columns": [], "rows": []}
+ n = min(len(frame.columns), 8 if quarterly else 4)
+ col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]]
+ if not quarterly:
+ col_labels.append("TTM")
+
+ def v(label: str) -> list[float | None]:
+ base = _row_vals(frame, label, n)
+ return base + ([_statement_ttm(frame_q, label)] if not quarterly else [])
+
+ def vm(*labels: str) -> list[float | None]:
+ base = _row_vals_multi(frame, n, *labels)
+ if not quarterly:
+ ttm = None
+ for lbl in labels:
+ ttm = _statement_ttm(frame_q, lbl)
+ if ttm is not None:
+ break
+ base = base + [ttm]
+ return base
+
+ rev = v("Total Revenue")
+ gross = v("Gross Profit")
+ net = v("Net Income")
+
+ return {
+ "columns": col_labels,
+ "rows": [
+ _fin_row("Total Revenue", 0, True, rev),
+ _fin_row("Cost of Revenue", 1, False, v("Cost Of Revenue")),
+ _fin_row("Gross Profit", 0, True, gross),
+ _fin_margin("gross margin", [_safe_ratio(g, r) for g, r in zip(gross, rev)]),
+ _fin_row("Operating Expenses", 1, False, v("Operating Expense")),
+ _fin_row("Operating Income", 0, True, v("Operating Income")),
+ _fin_row("EBITDA", 1, False, vm("EBITDA", "Normalized EBITDA")),
+ _fin_row("Interest Expense", 1, False, v("Interest Expense")),
+ _fin_row("Pretax Income", 0, False, v("Pretax Income")),
+ _fin_row("Tax Provision", 1, False, v("Tax Provision")),
+ _fin_row("Net Income", 0, True, net),
+ _fin_margin("net margin", [_safe_ratio(ni, r) for ni, r in zip(net, rev)]),
+ _fin_row("EPS Basic", 1, False, v("Basic EPS")),
+ ],
+ }
+
+
+def _build_balance(frame: pd.DataFrame, frame_q: pd.DataFrame, quarterly: bool) -> dict:
+ if frame is None or frame.empty:
+ return {"columns": [], "rows": []}
+ n = min(len(frame.columns), 8 if quarterly else 4)
+ col_labels = [_fmt_col(c, quarterly) for c in frame.columns[:n]]
+ if not quarterly:
+ col_labels.append("MRQ")
+
+ def v(*labels: str) -> list[float | None]:
+ base = _row_vals_multi(frame, n, *labels)
+ if not quarterly:
+ val = None
+ for lbl in labels:
+ val = _balance_value(frame_q, lbl)
+ if val is not None:
+ break
+ base = base + [val]
+ return base
+
+ return {
+ "columns": col_labels,
+ "rows": [
+ _fin_section("ASSETS"),
+ _fin_row("Current Assets", 0, True, v("Current Assets")),
+ _fin_row("Cash & Equivalents", 1, False, v("Cash And Cash Equivalents", "Cash Cash Equivalents And Short Term Investments")),
+ _fin_row("Short Term Investments", 1, False, v("Other Short Term Investments", "Short Term Investments")),
+ _fin_row("Receivables", 1, False, v("Receivables", "Net Receivables")),
+ _fin_row("Inventory", 1, False, v("Inventory")),
+ _fin_row("Total Assets", 0, True, v("Total Assets")),
+ _fin_section("LIABILITIES"),
+ _fin_row("Current Liabilities", 0, True, v("Current Liabilities")),
+ _fin_row("Accounts Payable", 1, False, v("Payables And Accrued Expenses", "Accounts Payable")),
+ _fin_row("Short Term Debt", 1, False, v("Current Debt", "Short Term Debt And Capital Lease Obligation")),
+ _fin_row("Long Term Debt", 1, False, v("Long Term Debt", "Long Term Debt And Capital Lease Obligation")),
+ _fin_row("Total Liabilities", 0, True, v("Total Liabilities Net Minority Interest", "Total Liabilities")),
+ _fin_section("EQUITY"),
+ _fin_row("Stockholders Equity", 0, True, v("Stockholders Equity", "Common Stock Equity")),
+ ],
+ }
+
+
+def _build_cash_flow(cf: pd.DataFrame, cf_q: pd.DataFrame, inc: pd.DataFrame, inc_q: pd.DataFrame, quarterly: bool) -> dict:
+ if cf is None or cf.empty:
+ return {"columns": [], "rows": []}
+ n = min(len(cf.columns), 8 if quarterly else 4)
+ col_labels = [_fmt_col(c, quarterly) for c in cf.columns[:n]]
+ if not quarterly:
+ col_labels.append("TTM")
+
+ def cv(*labels: str) -> list[float | None]:
+ base = _row_vals_multi(cf, n, *labels)
+ if not quarterly:
+ ttm = None
+ for lbl in labels:
+ ttm = _statement_ttm(cf_q, lbl)
+ if ttm is not None:
+ break
+ base = base + [ttm]
+ return base
+
+ def iv(*labels: str) -> list[float | None]:
+ base = _row_vals_multi(inc, n, *labels)
+ if not quarterly:
+ ttm = None
+ for lbl in labels:
+ ttm = _statement_ttm(inc_q, lbl)
+ if ttm is not None:
+ break
+ base = base + [ttm]
+ return base
+
+ op_cf = cv("Operating Cash Flow", "Cash Flow From Continuing Operating Activities")
+ capex = cv("Capital Expenditure")
+ # CapEx is negative in yfinance; FCF = Operating CF + CapEx
+ fcf = [a + b if a is not None and b is not None else None for a, b in zip(op_cf, capex)]
+ rev = iv("Total Revenue")
+
+ return {
+ "columns": col_labels,
+ "rows": [
+ _fin_section("OPERATING"),
+ _fin_row("Net Income", 1, False, iv("Net Income")),
+ _fin_row("D&A", 1, False, cv("Depreciation And Amortization", "Reconciled Depreciation")),
+ _fin_row("Changes in Working Capital", 1, False, cv("Change In Working Capital")),
+ _fin_row("Operating Cash Flow", 0, True, op_cf),
+ _fin_section("INVESTING"),
+ _fin_row("CapEx", 1, False, capex),
+ _fin_row("Free Cash Flow", 0, True, fcf),
+ _fin_margin("FCF margin", [_safe_ratio(f, r) for f, r in zip(fcf, rev)]),
+ _fin_row("Investing Cash Flow", 0, True, cv("Investing Cash Flow", "Cash Flow From Continuing Investing Activities")),
+ _fin_section("FINANCING"),
+ _fin_row("Dividends Paid", 1, False, cv("Cash Dividends Paid", "Common Stock Dividend Paid")),
+ _fin_row("Buybacks", 1, False, cv("Repurchase Of Capital Stock", "Common Stock Repurchase")),
+ _fin_row("Financing Cash Flow", 0, True, cv("Financing Cash Flow", "Cash Flow From Continuing Financing Activities")),
+ _fin_row("Net Change in Cash", 0, True, cv("Changes In Cash", "End Cash Position")),
+ ],
+ }
+
+
+@cached(FINANCIALS_CACHE)
+def get_financials(symbol: str, period: str = "annual") -> dict:
+ sym = normalize_symbol(symbol)
+ quarterly = period == "quarterly"
+
+ inc = get_income_statement(sym, quarterly=quarterly)
+ bal = get_balance_sheet(sym, quarterly=quarterly)
+ cf = get_cash_flow(sym, quarterly=quarterly)
+
+ inc_q = get_income_statement(sym, quarterly=True) if not quarterly else inc
+ bal_q = get_balance_sheet(sym, quarterly=True) if not quarterly else bal
+ cf_q = get_cash_flow(sym, quarterly=True) if not quarterly else cf
+
+ return {
+ "period": period,
+ "income": _build_income(inc, inc_q, quarterly),
+ "balance": _build_balance(bal, bal_q, quarterly),
+ "cash_flow": _build_cash_flow(cf, cf_q, inc, inc_q, quarterly),
+ }
+
+
def _balance_value(frame: pd.DataFrame, *labels: str) -> float | None:
if frame is None or frame.empty:
return None