summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/prism-shell.css193
-rw-r--r--frontend/components/prism/FinancialsCard.tsx20
-rw-r--r--frontend/components/prism/FinancialsPage.tsx68
-rw-r--r--frontend/components/prism/RatiosCard.tsx212
-rw-r--r--frontend/components/prism/RatiosPage.tsx57
-rw-r--r--frontend/lib/api.ts7
-rw-r--r--frontend/types/api.ts30
7 files changed, 548 insertions, 39 deletions
diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css
index 9a37bdd..4e65ced 100644
--- a/frontend/app/prism-shell.css
+++ b/frontend/app/prism-shell.css
@@ -1116,6 +1116,11 @@
overflow: hidden;
}
+.psm-fin-tab-bar {
+ border-bottom: 1px solid var(--line-1);
+ margin-bottom: 0;
+}
+
.psm-fin-header {
display: flex;
align-items: stretch;
@@ -1156,6 +1161,7 @@
display: flex;
align-items: center;
gap: var(--sp-1);
+ margin-left: auto;
}
.psm-fin-period-btn {
@@ -1490,3 +1496,190 @@
font-variant-numeric: tabular-nums;
text-align: right;
}
+
+/* ── Key ratios tab ─────────────────────────────── */
+
+.psm-ratio-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+}
+
+.psm-ratio-heroes {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--sp-3);
+}
+
+.psm-ratio-hero {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-3);
+ min-width: 0;
+ padding: var(--sp-4);
+ background: var(--ink-2);
+ border: 1px solid var(--line-1);
+ border-radius: var(--r-2);
+}
+
+.psm-ratio-hero-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--sp-3);
+}
+
+.psm-ratio-hero-label {
+ color: var(--fg-3);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+}
+
+.psm-ratio-hero-sector {
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.psm-ratio-hero-value {
+ font-family: var(--font-mono);
+ font-size: var(--fs-32);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-ratio-hero-spark {
+ width: 100%;
+ height: 52px;
+}
+
+.psm-ratio-detail {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-5);
+}
+
+.psm-ratio-group-label {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 96px 96px 88px;
+ gap: var(--sp-3);
+ align-items: center;
+ padding-bottom: var(--sp-2);
+ border-bottom: 1px solid var(--line-1);
+ color: var(--fg-4);
+ font-size: var(--fs-12);
+ font-weight: 600;
+ letter-spacing: var(--tr-wider);
+ text-transform: uppercase;
+}
+
+.psm-ratio-group-label span:nth-child(2),
+.psm-ratio-group-label span:nth-child(3) {
+ text-align: right;
+}
+
+.psm-ratio-group-label span:last-child {
+ text-align: center;
+}
+
+.psm-ratio-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 96px 96px 88px;
+ gap: var(--sp-3);
+ align-items: center;
+ padding: var(--sp-3) 0;
+ border-bottom: 1px solid var(--ink-2);
+}
+
+.psm-ratio-row-label {
+ color: var(--fg-2);
+ font-size: var(--fs-13);
+}
+
+.psm-ratio-row-value,
+.psm-ratio-row-sector {
+ color: var(--fg-1);
+ font-family: var(--font-mono);
+ font-size: var(--fs-13);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ white-space: nowrap;
+}
+
+.psm-ratio-row-sector {
+ color: var(--fg-4);
+}
+
+.psm-ratio-mini-spark {
+ width: 88px;
+ height: 24px;
+}
+
+.psm-ratio-mini-spark,
+.psm-ratio-hero-spark {
+ display: block;
+}
+
+.psm-ratio-spark-empty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 88px;
+ height: 24px;
+ color: var(--fg-4);
+ font-family: var(--font-mono);
+ font-size: var(--fs-12);
+ font-variant-numeric: tabular-nums;
+}
+
+.psm-ratio-hero .psm-ratio-spark-empty {
+ width: 100%;
+ height: 52px;
+ justify-content: flex-start;
+}
+
+@media (max-width: 980px) {
+ .psm-ratio-group-label,
+ .psm-ratio-row {
+ grid-template-columns: minmax(0, 1fr) 88px 88px 72px;
+ }
+
+ .psm-ratio-mini-spark,
+ .psm-ratio-spark-empty {
+ width: 72px;
+ }
+}
+
+@media (max-width: 680px) {
+ .psm-ratio-heroes {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .psm-ratio-group-label {
+ grid-template-columns: minmax(0, 1fr) 84px 84px 64px;
+ gap: var(--sp-2);
+ }
+
+ .psm-ratio-row {
+ grid-template-columns: minmax(0, 1fr) 84px 84px 64px;
+ gap: var(--sp-2);
+ }
+
+ .psm-ratio-hero-head {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .psm-ratio-hero-sector {
+ text-align: left;
+ }
+
+ .psm-ratio-mini-spark,
+ .psm-ratio-spark-empty {
+ width: 64px;
+ }
+}
diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx
index 94a6618..43a2dc2 100644
--- a/frontend/components/prism/FinancialsCard.tsx
+++ b/frontend/components/prism/FinancialsCard.tsx
@@ -9,16 +9,9 @@ type Props = {
data: FinancialsResponse;
statement: StatementKey;
period: PeriodKey;
- onChangeStatement: (s: StatementKey) => void;
onChangePeriod: (p: PeriodKey) => void;
};
-const STMT_LABELS: Record<StatementKey, string> = {
- income: "INCOME",
- balance: "BALANCE",
- cash_flow: "CASH FLOW",
-};
-
function fmtFinVal(val: number | null | undefined, isMargin: boolean): string {
if (val === null || val === undefined) return "—";
if (isMargin) return `${(val * 100).toFixed(1)}%`;
@@ -70,7 +63,6 @@ export function FinancialsCard({
data,
statement,
period,
- onChangeStatement,
onChangePeriod,
}: Props) {
const stmt = data[statement];
@@ -79,18 +71,6 @@ export function FinancialsCard({
return (
<section className="psm-card psm-financials-card">
<div className="psm-fin-header">
- <div className="psm-fin-tabs">
- {(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => (
- <button
- key={key}
- type="button"
- className={`psm-fin-tab${statement === key ? " active" : ""}`}
- onClick={() => onChangeStatement(key)}
- >
- {STMT_LABELS[key]}
- </button>
- ))}
- </div>
<div className="psm-fin-period">
{(["annual", "quarterly"] as PeriodKey[]).map((p) => (
<button
diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx
index fcd2763..9a56f2c 100644
--- a/frontend/components/prism/FinancialsPage.tsx
+++ b/frontend/components/prism/FinancialsPage.tsx
@@ -4,10 +4,12 @@ import { api } from "@/lib/api";
import { buildKpis } from "@/lib/overview";
import { FinancialsCard } from "@/components/prism/FinancialsCard";
import { KPIStrip } from "@/components/prism/KPIStrip";
+import { RatiosPage } from "@/components/prism/RatiosPage";
import { TickerHeader } from "@/components/prism/TickerHeader";
import type { FinancialsResponse, TickerOverview } from "@/types/api";
-type StatementKey = "income" | "balance" | "cash_flow";
+type StatementKey = "income" | "balance" | "cash_flow" | "ratios";
+type FinancialStatementKey = Exclude<StatementKey, "ratios">;
type PeriodKey = "annual" | "quarterly";
type FinState = "loading" | "ready" | "error";
@@ -18,6 +20,13 @@ type Props = {
onToggleWatchlist: () => void;
};
+const STATEMENT_LABELS: Record<StatementKey, string> = {
+ income: "INCOME",
+ balance: "BALANCE",
+ cash_flow: "CASH FLOW",
+ ratios: "RATIOS",
+};
+
export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
const [statement, setStatement] = useState<StatementKey>("income");
const [period, setPeriod] = useState<PeriodKey>("annual");
@@ -26,6 +35,10 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }:
const kpis = buildKpis(overview);
useEffect(() => {
+ if (statement === "ratios") {
+ return;
+ }
+
let cancelled = false;
setFinState("loading");
setData(null);
@@ -45,28 +58,47 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }:
return () => {
cancelled = true;
};
- }, [ticker, period]);
+ }, [ticker, period, statement]);
return (
<>
<TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} />
<KPIStrip items={kpis} />
- {finState === "loading" && (
- <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />
- )}
- {finState === "error" && (
- <section className="psm-card">
- <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p>
- </section>
- )}
- {finState === "ready" && data && (
- <FinancialsCard
- data={data}
- statement={statement}
- period={period}
- onChangeStatement={setStatement}
- onChangePeriod={setPeriod}
- />
+ <section className="psm-fin-tab-bar">
+ <div className="psm-fin-tabs">
+ {(["income", "balance", "cash_flow", "ratios"] as StatementKey[]).map((key) => (
+ <button
+ key={key}
+ type="button"
+ className={`psm-fin-tab${statement === key ? " active" : ""}`}
+ onClick={() => setStatement(key)}
+ >
+ {STATEMENT_LABELS[key]}
+ </button>
+ ))}
+ </div>
+ </section>
+ {statement === "ratios" ? (
+ <RatiosPage ticker={ticker} />
+ ) : (
+ <>
+ {finState === "loading" && (
+ <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />
+ )}
+ {finState === "error" && (
+ <section className="psm-card">
+ <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p>
+ </section>
+ )}
+ {finState === "ready" && data && (
+ <FinancialsCard
+ data={data}
+ statement={statement as FinancialStatementKey}
+ period={period}
+ onChangePeriod={setPeriod}
+ />
+ )}
+ </>
)}
</>
);
diff --git a/frontend/components/prism/RatiosCard.tsx b/frontend/components/prism/RatiosCard.tsx
new file mode 100644
index 0000000..1a00829
--- /dev/null
+++ b/frontend/components/prism/RatiosCard.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import type { RatioPoint, RatiosResponse } from "@/types/api";
+import { fmtNumber, fmtPct } from "@/lib/format";
+
+const BRASS = "#C2AA7A";
+const GAIN = "#4F8C5E";
+
+type Props = {
+ data: RatiosResponse;
+};
+
+type ValueKind = "multiple" | "percent" | "coverage";
+
+function buildLine(values: (number | null)[], width: number, height: number): string {
+ const numeric = values.filter((value): value is number => value != null && Number.isFinite(value));
+ if (numeric.length === 0) return "";
+ if (numeric.length === 1) {
+ const y = height / 2;
+ return `0,${y} ${width},${y}`;
+ }
+
+ const min = Math.min(...numeric);
+ const max = Math.max(...numeric);
+ const range = max - min || 1;
+
+ return numeric
+ .map((value, index) => {
+ const x = (index / (numeric.length - 1)) * width;
+ const y = max === min ? height / 2 : height - ((value - min) / range) * height;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+}
+
+function fmtMultiple(value?: number | null): string {
+ if (value == null || Number.isNaN(value)) return "—";
+ return `${fmtNumber(value)}x`;
+}
+
+function fmtCoverage(value?: number | null): string {
+ if (value == null || Number.isNaN(value)) return "—";
+ return `${fmtNumber(value)}x`;
+}
+
+function formatValue(value: number | null, kind: ValueKind): string {
+ if (kind === "percent") return fmtPct(value);
+ if (kind === "coverage") return fmtCoverage(value);
+ return fmtMultiple(value);
+}
+
+function MiniSpark({ values, color }: { values: (number | null)[]; color: string }) {
+ const points = buildLine(values, 88, 24);
+ if (!points) {
+ return <span className="psm-ratio-spark-empty">—</span>;
+ }
+
+ return (
+ <svg className="psm-ratio-mini-spark" viewBox="0 0 88 24" aria-hidden="true">
+ <polyline
+ points={points}
+ fill="none"
+ stroke={color}
+ strokeWidth="1.8"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ );
+}
+
+function HeroSpark({ values, color }: { values: (number | null)[]; color: string }) {
+ const points = buildLine(values, 196, 52);
+ if (!points) {
+ return <span className="psm-ratio-spark-empty">No trend</span>;
+ }
+
+ return (
+ <svg className="psm-ratio-hero-spark" viewBox="0 0 196 52" aria-hidden="true">
+ <polyline
+ points={points}
+ fill="none"
+ stroke={color}
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ </svg>
+ );
+}
+
+function HeroCard({
+ label,
+ point,
+ kind,
+ color,
+}: {
+ label: string;
+ point: RatioPoint;
+ kind: ValueKind;
+ color: string;
+}) {
+ return (
+ <article className="psm-ratio-hero">
+ <div className="psm-ratio-hero-head">
+ <span className="psm-ratio-hero-label">{label}</span>
+ <span className="psm-ratio-hero-sector">
+ Sector {formatValue(point.vs_sector, kind)}
+ </span>
+ </div>
+ <div className="psm-ratio-hero-value" style={{ color }}>
+ {formatValue(point.value, kind)}
+ </div>
+ <HeroSpark values={point.spark} color={color} />
+ </article>
+ );
+}
+
+function DetailRow({
+ label,
+ point,
+ kind,
+ color,
+}: {
+ label: string;
+ point: RatioPoint;
+ kind: ValueKind;
+ color: string;
+}) {
+ return (
+ <div className="psm-ratio-row">
+ <span className="psm-ratio-row-label">{label}</span>
+ <span className="psm-ratio-row-value">{formatValue(point.value, kind)}</span>
+ <span className="psm-ratio-row-sector">{formatValue(point.vs_sector, kind)}</span>
+ <MiniSpark values={point.spark} color={color} />
+ </div>
+ );
+}
+
+function GroupHeader({ label }: { label: string }) {
+ return (
+ <div className="psm-ratio-group-label">
+ <span>{label}</span>
+ <span>Current</span>
+ <span>Sector</span>
+ <span>Trend</span>
+ </div>
+ );
+}
+
+export function RatiosCard({ data }: Props) {
+ const showDividends = data.dividend_yield.value != null || data.dividend_payout.value != null;
+
+ return (
+ <section className="psm-card psm-ratio-card">
+ <div className="psm-card-head">
+ <div>
+ <div className="psm-eyebrow">Key Ratios</div>
+ <h2 className="psm-card-title">Key Ratios</h2>
+ </div>
+ </div>
+
+ <div className="psm-ratio-heroes">
+ <HeroCard label="P / E TTM" point={data.pe_ttm} kind="multiple" color={BRASS} />
+ <HeroCard label="EV / EBITDA" point={data.ev_ebitda} kind="multiple" color={BRASS} />
+ <HeroCard label="Gross Margin" point={data.gross_margin} kind="percent" color={GAIN} />
+ <HeroCard label="Net Margin" point={data.net_margin} kind="percent" color={GAIN} />
+ </div>
+
+ <div className="psm-ratio-detail">
+ <section>
+ <GroupHeader label="Valuation" />
+ <DetailRow label="P / Book" point={data.price_to_book} kind="multiple" color={BRASS} />
+ <DetailRow label="P / Sales" point={data.price_to_sales} kind="multiple" color={BRASS} />
+ <DetailRow label="EV / Sales" point={data.ev_to_sales} kind="multiple" color={BRASS} />
+ <DetailRow label="P / FCF" point={data.p_fcf} kind="multiple" color={BRASS} />
+ <DetailRow label="Forward P / E" point={data.forward_pe} kind="multiple" color={BRASS} />
+ </section>
+
+ <section>
+ <GroupHeader label="Profitability" />
+ <DetailRow label="Operating Margin" point={data.operating_margin} kind="percent" color={GAIN} />
+ <DetailRow label="EBITDA Margin" point={data.ebitda_margin} kind="percent" color={GAIN} />
+ <DetailRow label="FCF Margin" point={data.fcf_margin} kind="percent" color={GAIN} />
+ </section>
+
+ <section>
+ <GroupHeader label="Returns" />
+ <DetailRow label="ROE" point={data.roe} kind="percent" color={GAIN} />
+ <DetailRow label="ROA" point={data.roa} kind="percent" color={GAIN} />
+ <DetailRow label="ROIC" point={data.roic} kind="percent" color={GAIN} />
+ </section>
+
+ <section>
+ <GroupHeader label="Leverage / Liquidity" />
+ <DetailRow label="Debt / Equity" point={data.debt_to_equity} kind="multiple" color={BRASS} />
+ <DetailRow label="Current Ratio" point={data.current_ratio} kind="multiple" color={BRASS} />
+ <DetailRow label="Quick Ratio" point={data.quick_ratio} kind="multiple" color={BRASS} />
+ <DetailRow label="Interest Coverage" point={data.interest_coverage} kind="coverage" color={BRASS} />
+ </section>
+
+ {showDividends && (
+ <section>
+ <GroupHeader label="Dividends" />
+ <DetailRow label="Dividend Yield" point={data.dividend_yield} kind="percent" color={GAIN} />
+ <DetailRow label="Payout Ratio" point={data.dividend_payout} kind="percent" color={GAIN} />
+ </section>
+ )}
+ </div>
+ </section>
+ );
+}
diff --git a/frontend/components/prism/RatiosPage.tsx b/frontend/components/prism/RatiosPage.tsx
new file mode 100644
index 0000000..26868f8
--- /dev/null
+++ b/frontend/components/prism/RatiosPage.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { api } from "@/lib/api";
+import { RatiosCard } from "@/components/prism/RatiosCard";
+import type { RatiosResponse } from "@/types/api";
+
+type RatiosState = "loading" | "ready" | "error";
+
+type Props = {
+ ticker: string;
+};
+
+export function RatiosPage({ ticker }: Props) {
+ const [data, setData] = useState<RatiosResponse | null>(null);
+ const [ratiosState, setRatiosState] = useState<RatiosState>("loading");
+
+ useEffect(() => {
+ let cancelled = false;
+ setRatiosState("loading");
+ setData(null);
+
+ api
+ .ratios(ticker)
+ .then((res) => {
+ if (!cancelled) {
+ setData(res);
+ setRatiosState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setRatiosState("error");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ticker]);
+
+ if (ratiosState === "loading") {
+ return <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />;
+ }
+
+ if (ratiosState === "error") {
+ return (
+ <section className="psm-card">
+ <p className="psm-muted-copy">Ratio data unavailable for {ticker}.</p>
+ </section>
+ );
+ }
+
+ if (ratiosState === "ready" && data) {
+ return <RatiosCard data={data} />;
+ }
+
+ return null;
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 53b3dd3..b23edee 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,4 +1,4 @@
-import type { FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api";
+import type { FinancialsResponse, HistoryPoint, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
@@ -58,5 +58,10 @@ export const api = {
return request<ValuationResponse>(
`/api/tickers/${encodeURIComponent(symbol)}/valuation`
);
+ },
+ ratios(symbol: string) {
+ return request<RatiosResponse>(
+ `/api/tickers/${encodeURIComponent(symbol)}/ratios`
+ );
}
};
diff --git a/frontend/types/api.ts b/frontend/types/api.ts
index 998f618..7efe628 100644
--- a/frontend/types/api.ts
+++ b/frontend/types/api.ts
@@ -156,3 +156,33 @@ export type ValuationResponse = {
ev_revenue: MultipleResult;
price_to_book: MultipleResult;
};
+
+export type RatioPoint = {
+ value: number | null;
+ spark: (number | null)[];
+ vs_sector: number | null;
+};
+
+export type RatiosResponse = {
+ pe_ttm: RatioPoint;
+ ev_ebitda: RatioPoint;
+ gross_margin: RatioPoint;
+ net_margin: RatioPoint;
+ price_to_book: RatioPoint;
+ price_to_sales: RatioPoint;
+ ev_to_sales: RatioPoint;
+ p_fcf: RatioPoint;
+ forward_pe: RatioPoint;
+ operating_margin: RatioPoint;
+ ebitda_margin: RatioPoint;
+ fcf_margin: RatioPoint;
+ roe: RatioPoint;
+ roa: RatioPoint;
+ roic: RatioPoint;
+ debt_to_equity: RatioPoint;
+ current_ratio: RatioPoint;
+ quick_ratio: RatioPoint;
+ interest_coverage: RatioPoint;
+ dividend_yield: RatioPoint;
+ dividend_payout: RatioPoint;
+};