mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-23 15:48:05 +00:00
phase 6: financial statements
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user