From 553ec5347f3c8cf30abaeb273d9e14c0125af678 Mon Sep 17 00:00:00 2001 From: shawnkim1997 Date: Wed, 22 Apr 2026 11:15:01 +0100 Subject: [PATCH] phase 6: financial statements --- .../components/overview/EquityOverview.tsx | 2 + .../overview/FinancialStatements.tsx | 159 ++++++++++++++++++ atlas-terminal/server/core/cache.py | 4 + atlas-terminal/server/core/chained_gateway.py | 3 + atlas-terminal/server/core/data_gateway.py | 2 + atlas-terminal/server/core/providers/base.py | 3 + atlas-terminal/server/core/providers/fmp.py | 36 ++++ .../server/core/providers/yfinance.py | 43 +++++ atlas-terminal/server/routers/financials.py | 26 ++- atlas-terminal/tests/test_smoke.py | 23 +++ 10 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 atlas-terminal/apps/web/src/app/components/overview/FinancialStatements.tsx diff --git a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx index cf4a376..ef32a0d 100644 --- a/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx +++ b/atlas-terminal/apps/web/src/app/components/overview/EquityOverview.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; +import { FinancialStatements } from "./FinancialStatements"; import { Card } from "../ui/Card"; import { SectionHeading } from "../ui/SectionHeading"; import { StatCard } from "../ui/StatCard"; @@ -84,6 +85,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps) )} + ); } diff --git a/atlas-terminal/apps/web/src/app/components/overview/FinancialStatements.tsx b/atlas-terminal/apps/web/src/app/components/overview/FinancialStatements.tsx new file mode 100644 index 0000000..7fb4412 --- /dev/null +++ b/atlas-terminal/apps/web/src/app/components/overview/FinancialStatements.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { ErrorBanner } from "../ui/ErrorBanner"; +import { LoadingPulse } from "../ui/LoadingPulse"; +import { flags } from "../../lib/flags"; +import { useApi } from "../../lib/use-api"; + +type StatementKey = "income" | "balance" | "cashflow"; +type PeriodKey = "annual" | "quarter"; + +interface StatementResponse { + ticker: string; + statement: string; + period: string; + source: string; + periods: string[]; + line_items: Record>; +} + +const STATEMENTS: { key: StatementKey; label: string }[] = [ + { key: "income", label: "Income" }, + { key: "balance", label: "Balance Sheet" }, + { key: "cashflow", label: "Cash Flow" }, +]; + +const LINE_LABELS: Record = { + revenue: "Revenue", + totalRevenue: "Revenue", + TotalRevenue: "Revenue", + costOfRevenue: "Cost of Revenue", + grossProfit: "Gross Profit", + GrossProfit: "Gross Profit", + operatingIncome: "Operating Income", + OperatingIncome: "Operating Income", + netIncome: "Net Income", + NetIncome: "Net Income", + totalAssets: "Total Assets", + TotalAssets: "Total Assets", + totalLiabilities: "Total Liabilities", + TotalLiabilitiesNetMinorityInterest: "Total Liabilities", + totalStockholdersEquity: "Equity", + StockholdersEquity: "Equity", + operatingCashFlow: "Operating Cash Flow", + OperatingCashFlow: "Operating Cash Flow", + capitalExpenditure: "Capex", + CapitalExpenditure: "Capex", + freeCashFlow: "Free Cash Flow", +}; + +function formatLineLabel(key: string): string { + return LINE_LABELS[key] || key.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " "); +} + +function formatValue(value: number | null): string { + if (value == null) return "—"; + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + if (abs >= 1e12) return `${sign}$${(abs / 1e12).toFixed(2)}T`; + if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(1)}B`; + if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(1)}M`; + return `${sign}$${abs.toFixed(0)}`; +} + +function yoy(values: Array, idx: number): number | null { + const current = values[idx]; + const previous = values[idx + 1]; + if (current == null || previous == null || previous === 0) return null; + return ((current - previous) / Math.abs(previous)) * 100; +} + +function Sparkline({ values }: { values: Array }) { + const nums = values.filter((value): value is number => value != null); + if (nums.length < 2) return ; + const min = Math.min(...nums); + const max = Math.max(...nums); + const range = max - min || 1; + const points = values + .map((value, idx) => { + const v = value ?? min; + return `${idx * 16},${24 - ((v - min) / range) * 22}`; + }) + .join(" "); + return ( + + ); +} + +export function FinancialStatements({ ticker }: { ticker: string }) { + const [statement, setStatement] = useState("income"); + const [period, setPeriod] = useState("annual"); + const url = flags.financials ? `/api/financials/${encodeURIComponent(ticker)}/table?statement=${statement}&period=${period}` : null; + const { data, loading, error } = useApi(url, { cacheTtlMs: 300_000 }); + const entries = data ? Object.entries(data.line_items).slice(0, 12) : []; + + if (!flags.financials) return null; + + return ( +
+
+
+

Financial Statements

+

Gateway-backed statement table with YoY deltas.

+
+
+ {STATEMENTS.map((item) => ( + + ))} + {(["annual", "quarter"] as PeriodKey[]).map((item) => ( + + ))} +
+
+ + {loading ? : ( +
+ + + + + {(data?.periods || []).map((p) => )} + + + + + {entries.length > 0 ? entries.map(([key, values]) => ( + + + {values.slice(0, data?.periods.length || 0).map((value, idx) => { + const growth = yoy(values, idx); + return ( + + ); + })} + + + )) : ( + + + + )} + +
Line Item{p}Trend
{formatLineLabel(key)} +
{formatValue(value)}
+ {growth != null &&
= 0 ? "text-fin-positive" : "text-fin-negative"}`}>{growth >= 0 ? "+" : ""}{growth.toFixed(1)}%
} +
+ No statement data available. +
+
+ )} +
+ ); +} diff --git a/atlas-terminal/server/core/cache.py b/atlas-terminal/server/core/cache.py index fd61ab7..9ad12f1 100644 --- a/atlas-terminal/server/core/cache.py +++ b/atlas-terminal/server/core/cache.py @@ -24,6 +24,7 @@ class CachedGateway(DataGateway): "quote": 30, "profile": 86_400, "fundamentals": 43_200, + "financials": 43_200, "segments": 604_800, "history": 300, "news": 300, @@ -64,6 +65,9 @@ class CachedGateway(DataGateway): async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: return await self._cached("fundamentals", (symbol, period), lambda: self.inner.fundamentals(symbol, period)) + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict[str, Any]: + return await self._cached("financials", (symbol, statement, period), lambda: self.inner.financials(symbol, statement, period)) + async def history(self, symbol: str, range: str = "1y") -> OHLCV: return await self._cached("history", (symbol, range), lambda: self.inner.history(symbol, range)) diff --git a/atlas-terminal/server/core/chained_gateway.py b/atlas-terminal/server/core/chained_gateway.py index c97d221..e450c90 100644 --- a/atlas-terminal/server/core/chained_gateway.py +++ b/atlas-terminal/server/core/chained_gateway.py @@ -57,6 +57,9 @@ class ChainedGateway(DataGateway): async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: return await self._try(symbol, "fundamentals", lambda provider: provider.fundamentals(symbol, period)) + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict: + return await self._try(symbol, "financials", lambda provider: provider.financials(symbol, statement, period)) + async def history(self, symbol: str, range: str = "1y") -> OHLCV: return await self._try(symbol, "history", lambda provider: provider.history(symbol, range)) diff --git a/atlas-terminal/server/core/data_gateway.py b/atlas-terminal/server/core/data_gateway.py index bd72aca..a9a6cfa 100644 --- a/atlas-terminal/server/core/data_gateway.py +++ b/atlas-terminal/server/core/data_gateway.py @@ -130,6 +130,8 @@ class DataGateway(Protocol): async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: ... + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict[str, Any]: ... + async def history(self, symbol: str, range: str = "1y") -> OHLCV: ... async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: ... diff --git a/atlas-terminal/server/core/providers/base.py b/atlas-terminal/server/core/providers/base.py index 03195b8..25ea065 100644 --- a/atlas-terminal/server/core/providers/base.py +++ b/atlas-terminal/server/core/providers/base.py @@ -55,6 +55,9 @@ class BaseProvider: async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: raise ProviderNotImplemented(f"{self.name}.fundamentals") + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict[str, Any]: + raise ProviderNotImplemented(f"{self.name}.financials") + async def history(self, symbol: str, range: str = "1y") -> OHLCV: raise ProviderNotImplemented(f"{self.name}.history") diff --git a/atlas-terminal/server/core/providers/fmp.py b/atlas-terminal/server/core/providers/fmp.py index 7f54145..35879e8 100644 --- a/atlas-terminal/server/core/providers/fmp.py +++ b/atlas-terminal/server/core/providers/fmp.py @@ -59,3 +59,39 @@ class FMPProvider(BaseProvider): async def segments(self, symbol: str) -> list[Segment]: raise ProviderNotImplemented("FMP segments parser is planned for Phase 1.4") + + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict[str, object]: + normalized = symbol.strip().upper() + statement_key = statement.strip().lower() + path_map = { + "income": "income-statement", + "balance": "balance-sheet-statement", + "cashflow": "cash-flow-statement", + "cash_flow": "cash-flow-statement", + } + path = path_map.get(statement_key) + if not path: + raise ProviderError(f"unsupported statement: {statement}") + period_key = "quarter" if period.strip().lower().startswith("q") else "annual" + data = await self._get_json(f"/{path}/{normalized}", {"period": period_key, "limit": 5}) + rows = data if isinstance(data, list) else [] + if not rows: + raise ProviderError("missing financial statement rows") + periods = [str(row.get("date") or row.get("calendarYear") or idx) for idx, row in enumerate(rows)] + line_items: dict[str, list[float | int | None]] = {} + for row in rows: + if not isinstance(row, dict): + continue + for key, value in row.items(): + if key in {"date", "symbol", "reportedCurrency", "cik", "fillingDate", "acceptedDate", "calendarYear", "period", "link", "finalLink"}: + continue + if isinstance(value, (int, float)) or value is None: + line_items.setdefault(key, []).append(value) + return { + "ticker": normalized, + "statement": statement_key, + "period": period_key, + "source": self.name, + "periods": periods, + "line_items": line_items, + } diff --git a/atlas-terminal/server/core/providers/yfinance.py b/atlas-terminal/server/core/providers/yfinance.py index e8d77aa..634fe68 100644 --- a/atlas-terminal/server/core/providers/yfinance.py +++ b/atlas-terminal/server/core/providers/yfinance.py @@ -106,6 +106,49 @@ class YFinanceProvider(BaseProvider): return await self._to_thread(fetch) + async def financials(self, symbol: str, statement: str = "income", period: str = "annual") -> dict[str, Any]: + def fetch() -> dict[str, Any]: + import pandas as pd + + normalized = symbol.strip().upper() + ticker = self._ticker(normalized) + statement_key = statement.strip().lower() + quarterly = period.strip().lower().startswith("q") + if statement_key == "income": + df = ticker.quarterly_income_stmt if quarterly else ticker.income_stmt + elif statement_key == "balance": + df = ticker.quarterly_balance_sheet if quarterly else ticker.balance_sheet + elif statement_key in {"cashflow", "cash_flow"}: + df = ticker.quarterly_cashflow if quarterly else ticker.cashflow + else: + raise ProviderError(f"unsupported statement: {statement}") + if df is None or not isinstance(df, pd.DataFrame) or df.empty: + raise ProviderError("missing financial statement dataframe") + sliced = df.iloc[:, :5] + periods = [str(col)[:10] for col in sliced.columns] + line_items: dict[str, list[float | None]] = {} + for idx, row in sliced.iterrows(): + values: list[float | None] = [] + for value in row.tolist(): + try: + if pd.isna(value): + values.append(None) + else: + values.append(float(value)) + except (TypeError, ValueError): + values.append(None) + line_items[str(idx).replace(" ", "")] = values + return { + "ticker": normalized, + "statement": statement_key, + "period": "quarter" if quarterly else "annual", + "source": self.name, + "periods": periods, + "line_items": line_items, + } + + return await self._to_thread(fetch) + async def history(self, symbol: str, range: str = "1y") -> OHLCV: def fetch() -> OHLCV: hist = self._ticker(symbol).history(period=range) diff --git a/atlas-terminal/server/routers/financials.py b/atlas-terminal/server/routers/financials.py index 764160e..1348199 100644 --- a/atlas-terminal/server/routers/financials.py +++ b/atlas-terminal/server/routers/financials.py @@ -3,7 +3,8 @@ import logging from typing import Any, Dict, List, Optional -from fastapi import APIRouter +from fastapi import APIRouter, Query +from server.core.factory import get_data_gateway router = APIRouter() logger = logging.getLogger(__name__) @@ -59,6 +60,29 @@ def _safe_get(info: Dict[str, Any], key: str) -> Optional[float]: return float(val) +@router.get( + "/{ticker}/table", + summary="Gateway-backed financial statement table", +) +async def financial_statement_table( + ticker: str, + statement: str = Query("income", pattern="^(income|balance|cashflow|cash_flow)$"), + period: str = Query("annual", pattern="^(annual|quarter)$"), +) -> Dict[str, Any]: + try: + return await get_data_gateway().financials(ticker, statement, period) + except Exception as exc: + logger.warning("financial statement table failed for %s: %s", ticker, exc) + return { + "ticker": ticker.upper(), + "statement": statement, + "period": period, + "source": "unavailable", + "periods": [], + "line_items": {}, + } + + @router.get( "/{ticker}/statements", summary="Income statement, balance sheet, cash flow", diff --git a/atlas-terminal/tests/test_smoke.py b/atlas-terminal/tests/test_smoke.py index 1739f23..e85243e 100644 --- a/atlas-terminal/tests/test_smoke.py +++ b/atlas-terminal/tests/test_smoke.py @@ -172,3 +172,26 @@ def test_calendar_degrades_without_fmp_key(monkeypatch) -> None: data = response.json() assert data["available"] is False assert data["grouped"] == {} + + +def test_financial_statement_table_uses_gateway(monkeypatch) -> None: + from server.routers import financials + + class FakeGateway: + async def financials(self, ticker: str, statement: str = "income", period: str = "annual") -> dict: + return { + "ticker": ticker.upper(), + "statement": statement, + "period": period, + "source": "fake", + "periods": ["2025", "2024"], + "line_items": {"revenue": [120.0, 100.0]}, + } + + monkeypatch.setattr(financials, "get_data_gateway", lambda: FakeGateway()) + + with TestClient(app) as client: + response = client.get("/api/financials/AAPL/table?statement=income&period=annual") + + assert response.status_code == 200 + assert response.json()["line_items"]["revenue"] == [120.0, 100.0]