phase 6: financial statements

This commit is contained in:
shawnkim1997
2026-04-22 11:15:01 +01:00
parent b8602548f2
commit 553ec5347f
10 changed files with 300 additions and 1 deletions
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { KpiSection, type KpiHistoryData } from "./KpiSection"; import { KpiSection, type KpiHistoryData } from "./KpiSection";
import { PeerComparison, type PeerComparisonData } from "./PeerComparison"; import { PeerComparison, type PeerComparisonData } from "./PeerComparison";
import { FinancialStatements } from "./FinancialStatements";
import { Card } from "../ui/Card"; import { Card } from "../ui/Card";
import { SectionHeading } from "../ui/SectionHeading"; import { SectionHeading } from "../ui/SectionHeading";
import { StatCard } from "../ui/StatCard"; import { StatCard } from "../ui/StatCard";
@@ -84,6 +85,7 @@ export function EquityOverview({ ticker, sector, health }: EquityOverviewProps)
)} )}
</Card> </Card>
<PeerComparison currentTicker={ticker} data={peerData} /> <PeerComparison currentTicker={ticker} data={peerData} />
<FinancialStatements ticker={ticker} />
</div> </div>
); );
} }
@@ -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<string, Array<number | null>>;
}
const STATEMENTS: { key: StatementKey; label: string }[] = [
{ key: "income", label: "Income" },
{ key: "balance", label: "Balance Sheet" },
{ key: "cashflow", label: "Cash Flow" },
];
const LINE_LABELS: Record<string, string> = {
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<number | null>, 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<number | null> }) {
const nums = values.filter((value): value is number => value != null);
if (nums.length < 2) return <span className="text-text-muted"></span>;
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 (
<svg width="72" height="26" aria-hidden="true">
<polyline points={points} fill="none" stroke="#2E5B9A" strokeWidth="1.8" />
</svg>
);
}
export function FinancialStatements({ ticker }: { ticker: string }) {
const [statement, setStatement] = useState<StatementKey>("income");
const [period, setPeriod] = useState<PeriodKey>("annual");
const url = flags.financials ? `/api/financials/${encodeURIComponent(ticker)}/table?statement=${statement}&period=${period}` : null;
const { data, loading, error } = useApi<StatementResponse>(url, { cacheTtlMs: 300_000 });
const entries = data ? Object.entries(data.line_items).slice(0, 12) : [];
if (!flags.financials) return null;
return (
<div className="atlas-table-shell mt-6">
<div className="flex flex-wrap items-center justify-between gap-3 p-5">
<div>
<h3 className="font-serif text-lg font-bold text-brand-navy">Financial Statements</h3>
<p className="mt-1 text-xs text-text-muted">Gateway-backed statement table with YoY deltas.</p>
</div>
<div className="flex flex-wrap gap-2">
{STATEMENTS.map((item) => (
<button key={item.key} type="button" onClick={() => setStatement(item.key)} className={`rounded border px-3 py-1.5 text-xs font-semibold ${statement === item.key ? "border-brand-navy bg-brand-navy text-white" : "border-border text-text-secondary hover:bg-surface-sunken"}`}>
{item.label}
</button>
))}
{(["annual", "quarter"] as PeriodKey[]).map((item) => (
<button key={item} type="button" onClick={() => setPeriod(item)} className={`rounded border px-3 py-1.5 text-xs font-semibold capitalize ${period === item ? "border-brand-gold bg-brand-gold/20 text-brand-navy" : "border-border text-text-secondary hover:bg-surface-sunken"}`}>
{item}
</button>
))}
</div>
</div>
<ErrorBanner variant="error" message={error} className="mx-5 mb-4" />
{loading ? <LoadingPulse height="h-40" label="Loading statements..." /> : (
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-sm">
<thead className="bg-surface-sunken">
<tr className="border-y border-border-strong text-[11px] uppercase tracking-[0.12em] text-brand-navy">
<th className="px-5 py-3 text-left font-semibold">Line Item</th>
{(data?.periods || []).map((p) => <th key={p} className="px-3 py-3 text-right font-semibold">{p}</th>)}
<th className="px-5 py-3 text-right font-semibold">Trend</th>
</tr>
</thead>
<tbody>
{entries.length > 0 ? entries.map(([key, values]) => (
<tr key={key} className="border-b border-border/60 hover:bg-surface-sunken">
<td className="px-5 py-3 font-semibold text-brand-navy">{formatLineLabel(key)}</td>
{values.slice(0, data?.periods.length || 0).map((value, idx) => {
const growth = yoy(values, idx);
return (
<td key={`${key}-${idx}`} className="px-3 py-3 text-right font-mono tabular-nums text-text-primary">
<div>{formatValue(value)}</div>
{growth != null && <div className={`text-[10px] ${growth >= 0 ? "text-fin-positive" : "text-fin-negative"}`}>{growth >= 0 ? "+" : ""}{growth.toFixed(1)}%</div>}
</td>
);
})}
<td className="px-5 py-3 text-right"><Sparkline values={values} /></td>
</tr>
)) : (
<tr>
<td colSpan={(data?.periods.length || 0) + 2} className="px-5 py-8 text-center text-text-muted">
No statement data available.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+4
View File
@@ -24,6 +24,7 @@ class CachedGateway(DataGateway):
"quote": 30, "quote": 30,
"profile": 86_400, "profile": 86_400,
"fundamentals": 43_200, "fundamentals": 43_200,
"financials": 43_200,
"segments": 604_800, "segments": 604_800,
"history": 300, "history": 300,
"news": 300, "news": 300,
@@ -64,6 +65,9 @@ class CachedGateway(DataGateway):
async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals:
return await self._cached("fundamentals", (symbol, period), lambda: self.inner.fundamentals(symbol, period)) 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: async def history(self, symbol: str, range: str = "1y") -> OHLCV:
return await self._cached("history", (symbol, range), lambda: self.inner.history(symbol, range)) return await self._cached("history", (symbol, range), lambda: self.inner.history(symbol, range))
@@ -57,6 +57,9 @@ class ChainedGateway(DataGateway):
async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals:
return await self._try(symbol, "fundamentals", lambda provider: provider.fundamentals(symbol, period)) 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: async def history(self, symbol: str, range: str = "1y") -> OHLCV:
return await self._try(symbol, "history", lambda provider: provider.history(symbol, range)) return await self._try(symbol, "history", lambda provider: provider.history(symbol, range))
@@ -130,6 +130,8 @@ class DataGateway(Protocol):
async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: ... 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 history(self, symbol: str, range: str = "1y") -> OHLCV: ...
async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: ... async def news(self, symbols: list[str], limit: int = 20) -> list[Article]: ...
@@ -55,6 +55,9 @@ class BaseProvider:
async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals: async def fundamentals(self, symbol: str, period: str = "annual") -> Fundamentals:
raise ProviderNotImplemented(f"{self.name}.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: async def history(self, symbol: str, range: str = "1y") -> OHLCV:
raise ProviderNotImplemented(f"{self.name}.history") raise ProviderNotImplemented(f"{self.name}.history")
@@ -59,3 +59,39 @@ class FMPProvider(BaseProvider):
async def segments(self, symbol: str) -> list[Segment]: async def segments(self, symbol: str) -> list[Segment]:
raise ProviderNotImplemented("FMP segments parser is planned for Phase 1.4") 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,
}
@@ -106,6 +106,49 @@ class YFinanceProvider(BaseProvider):
return await self._to_thread(fetch) 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: async def history(self, symbol: str, range: str = "1y") -> OHLCV:
def fetch() -> OHLCV: def fetch() -> OHLCV:
hist = self._ticker(symbol).history(period=range) hist = self._ticker(symbol).history(period=range)
+25 -1
View File
@@ -3,7 +3,8 @@
import logging import logging
from typing import Any, Dict, List, Optional 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() router = APIRouter()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,6 +60,29 @@ def _safe_get(info: Dict[str, Any], key: str) -> Optional[float]:
return float(val) 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( @router.get(
"/{ticker}/statements", "/{ticker}/statements",
summary="Income statement, balance sheet, cash flow", summary="Income statement, balance sheet, cash flow",
+23
View File
@@ -172,3 +172,26 @@ def test_calendar_degrades_without_fmp_key(monkeypatch) -> None:
data = response.json() data = response.json()
assert data["available"] is False assert data["available"] is False
assert data["grouped"] == {} 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]