diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/prism-shell.css | 193 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsCard.tsx | 20 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsPage.tsx | 68 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosCard.tsx | 212 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosPage.tsx | 57 | ||||
| -rw-r--r-- | frontend/lib/api.ts | 7 | ||||
| -rw-r--r-- | frontend/types/api.ts | 30 |
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; +}; |
