SPB Git

spb/findata

Public
13.5 KB · 249 lines tsx
1"use client";2import { useEffect, useMemo, useState, type FormEvent } from "react";3import { Play } from "lucide-react";4import clsx from "clsx";5import { api, type Fact, type FundamentalsOut, type PeriodsOut } from "@/lib/api";6import { useQueryParam } from "@/lib/hooks";7import { fmtCompact, fmtInt, fmtNum, fmtTs, nowLocalInput } from "@/lib/format";8import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge, Tabs } from "@/components/ui";9import { DataTable } from "@/components/DataTable";1011const DEFAULT_PIVOT = 6;1213export default function FundamentalsPage() {14  const qTicker = useQueryParam("ticker");15  const [ticker, setTicker] = useState("AAPL");16  const [asOf, setAsOf] = useState(nowLocalInput());17  const [periodType, setPeriodType] = useState<"" | "q" | "a">("q");18  const [metrics, setMetrics] = useState("");19  const [out, setOut] = useState<FundamentalsOut | null>(null);20  const [periods, setPeriods] = useState<PeriodsOut | null>(null);21  const [busy, setBusy] = useState(false);22  const [error, setError] = useState<Error | null>(null);23  const [tab, setTab] = useState<"pivot" | "facts" | "periods" | "earnings">("pivot");24  const [pivotMetrics, setPivotMetrics] = useState<string[]>([]);2526  useEffect(() => {27    if (qTicker) setTicker(qTicker.toUpperCase());28  }, [qTicker]);2930  async function run(e?: FormEvent) {31    e?.preventDefault();32    setBusy(true);33    setError(null);34    try {35      const asOfIso = asOf ? new Date(asOf).toISOString() : undefined;36      const t = ticker.trim();37      const [f, p] = await Promise.all([38        api.get<FundamentalsOut>(`/fundamentals/${encodeURIComponent(t)}`, { as_of: asOfIso, metrics: metrics.trim() || undefined, period_type: periodType || undefined, limit: 5000 }),39        api.get<PeriodsOut>(`/fundamentals/${encodeURIComponent(t)}/periods`).catch(() => null),40      ]);41      setOut(f);42      setPeriods(p);43      const all = Array.from(new Set(f.data.map((x) => x.metric)));44      setPivotMetrics((cur) => (cur.length ? cur.filter((m) => all.includes(m)) : all.slice(0, DEFAULT_PIVOT)));45    } catch (err) {46      setError(err instanceof Error ? err : new Error(String(err)));47      setOut(null);48      setPeriods(null);49    } finally {50      setBusy(false);51    }52  }5354  const allMetrics = useMemo(() => Array.from(new Set((out?.data ?? []).map((x) => x.metric))).sort(), [out]);55  const pivot = useMemo(() => {56    const rows = new Map<string, { period: string; type: string; fy: number | null; fp: string | null; filing: string | null; values: Record<string, Fact> }>();57    for (const f of out?.data ?? []) {58      const key = `${f.fiscal_period_end}|${f.fiscal_period_type}`;59      const r = rows.get(key) ?? { period: f.fiscal_period_end, type: f.fiscal_period_type, fy: f.fiscal_year, fp: f.fiscal_period, filing: f.filing_date, values: {} };60      r.values[f.metric] = f;61      rows.set(key, r);62    }63    return Array.from(rows.values()).sort((a, b) => b.period.localeCompare(a.period));64  }, [out]);6566  return (67    <>68      <PageHeader title="Fundamentals" subtitle="Point-in-time facts: only what was knowable at the chosen instant is returned." />69      <Card className="mb-4">70        <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-5 md:items-end">71          <Field label="Ticker or entity id">72            <input className="input font-mono uppercase" value={ticker} onChange={(e) => setTicker(e.target.value)} required placeholder="AAPL or frd:AAPL" />73          </Field>74          <Field label="As of (local time → UTC)">75            <input type="datetime-local" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />76          </Field>77          <Field label="Period type">78            <select className="input" value={periodType} onChange={(e) => setPeriodType(e.target.value as typeof periodType)}>79              <option value="q">quarterly</option>80              <option value="a">annual</option>81              <option value="">both</option>82            </select>83          </Field>84          <Field label="Metrics (comma-separated)">85            <input className="input font-mono text-xs" value={metrics} onChange={(e) => setMetrics(e.target.value)} placeholder="rev, net_inc, eps" />86          </Field>87          <button className="btn btn-primary" disabled={busy || !ticker.trim()}>88            <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}89          </button>90        </form>91      </Card>9293      {error && <ErrorState error={error} retry={() => void run()} />}9495      {out && (96        <>97          <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">98            <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} facts</span>99            <span className="pill bg-surface-2 text-ink-2 tnum">as of {fmtTs(out.meta.as_of)}</span>100            {out.meta.facts_hidden_by_as_of > 0 && <span className="pill bg-warn-soft text-warn-ink tnum">{fmtInt(out.meta.facts_hidden_by_as_of)} facts hidden by as_of</span>}101            {out.entity && <span className="pill bg-surface-2 text-ink-2">{String(out.entity.name ?? out.entity.entity_id ?? "")}</span>}102            <span className="ml-auto">103              <Tabs value={tab} onChange={setTab} items={[{ value: "pivot", label: "Pivot" }, { value: "facts", label: "Raw facts" }, { value: "periods", label: `Periods (${periods?.periods.length ?? 0})` }, { value: "earnings", label: `Earnings (${periods?.earnings.length ?? 0})` }]} />104            </span>105          </div>106          <Banner tone="accent" className="mb-4">107            <span className="text-xs">PIT policy: {out.meta.pit_policy}. Revision history exists only from the first ingestion by this platform.</span>108          </Banner>109110          {tab === "pivot" && (111            <Card title="Periods × metrics" actions={<span className="text-xs text-ink-3">{pivotMetrics.length} of {allMetrics.length} metrics</span>}>112              <div className="mb-3 flex flex-wrap gap-1">113                {allMetrics.map((m) => {114                  const on = pivotMetrics.includes(m);115                  return (116                    <button key={m} className={clsx("pill border font-mono", on ? "border-accent bg-accent-soft text-accent-ink" : "border-border bg-surface-1 text-ink-2 hover:bg-surface-3")} onClick={() => setPivotMetrics((cur) => (on ? cur.filter((x) => x !== m) : [...cur, m]))} aria-pressed={on}>117                      {m}118                    </button>119                  );120                })}121              </div>122              {pivot.length === 0 ? (123                <Empty>No facts visible at this as-of instant.</Empty>124              ) : (125                <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}>126                  <table className="table">127                    <thead>128                      <tr>129                        <th>Period end</th>130                        <th>FY / FP</th>131                        <th>Filed</th>132                        {pivotMetrics.map((m) => (133                          <th key={m} className="text-right font-mono">134                            {m}135                          </th>136                        ))}137                      </tr>138                    </thead>139                    <tbody>140                      {pivot.map((r) => (141                        <tr key={`${r.period}-${r.type}`}>142                          <td className="tnum whitespace-nowrap">143                            {r.period} <span className="text-ink-3">{r.type}</span>144                          </td>145                          <td className="tnum text-xs text-ink-2 whitespace-nowrap">146                            {r.fy ?? "—"} / {r.fp ?? "—"}147                          </td>148                          <td className="tnum text-xs text-ink-2 whitespace-nowrap">{r.filing ?? "—"}</td>149                          {pivotMetrics.map((m) => {150                            const f = r.values[m];151                            return (152                              <td key={m} className="num whitespace-nowrap" title={f ? `${f.value} ${f.unit} · rev ${f.revision_id}${f.restatement_flag ? " · restated" : ""}` : undefined}>153                                {f ? (154                                  <>155                                    {fmtValue(f)}156                                    {f.revision_id > 1 && <sup className="ml-0.5 text-[9px] text-warn-ink">r{f.revision_id}</sup>}157                                  </>158                                ) : (159                                  <span className="text-ink-3">—</span>160                                )}161                              </td>162                            );163                          })}164                        </tr>165                      ))}166                    </tbody>167                  </table>168                </div>169              )}170              <p className="mt-2 text-[11px] text-ink-3">Values compacted (K/M/B); hover for exact value and unit. <sup>rN</sup> marks a revision observed before as-of.</p>171            </Card>172          )}173174          {tab === "facts" && (175            <Card title="Raw facts">176              <DataTable177                rows={out.data}178                rowKey={(f, i) => `${f.metric}-${f.fiscal_period_end}-${f.fiscal_period_type}-${f.revision_id}-${i}`}179                mobile="scroll"180                maxHeight="60dvh"181                columns={[182                  { key: "metric", header: "Metric", mono: true },183                  { key: "value", header: "Value", num: true, render: (f) => <span title={String(f.value)}>{fmtValue(f)}</span> },184                  { key: "unit", header: "Unit", hideSm: true },185                  { key: "fiscal_period_end", header: "Period end", render: (f) => <span className="tnum">{f.fiscal_period_end} <span className="text-ink-3">{f.fiscal_period_type}</span></span> },186                  { key: "fy", header: "FY/FP", hideSm: true, render: (f) => `${f.fiscal_year ?? "—"}/${f.fiscal_period ?? "—"}` },187                  { key: "filing_date", header: "Filed", render: (f) => <span className="tnum">{f.filing_date ?? "—"}</span> },188                  { key: "available_at", header: "Available at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.available_at)}</span> },189                  { key: "observed_at", header: "Observed at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.observed_at)}</span> },190                  { key: "revision_id", header: "Rev", num: true },191                  { key: "restatement_flag", header: "Restated", render: (f) => (f.restatement_flag ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },192                  { key: "source_document", header: "Source", hideSm: true, render: (f) => <span className="text-xs text-ink-2">{f.source_document ?? f.source_id}</span> },193                ]}194              />195            </Card>196          )}197198          {tab === "periods" && (199            <Card title="Fiscal periods (all revisions, not PIT-filtered)">200              <DataTable201                rows={periods?.periods}202                rowKey={(p) => `${p.fiscal_period_end}-${p.fiscal_period_type}-${p.filing_date}`}203                mobile="scroll"204                maxHeight="60dvh"205                columns={[206                  { key: "fiscal_period_end", header: "Period end", render: (p) => <span className="tnum">{p.fiscal_period_end} <span className="text-ink-3">{p.fiscal_period_type}</span></span> },207                  { key: "fy", header: "FY/FP", render: (p) => `${p.fiscal_year ?? "—"}/${p.fiscal_period ?? "—"}` },208                  { key: "filing_date", header: "Filed", render: (p) => <span className="tnum">{p.filing_date ?? "—"}</span> },209                  { key: "available_at", header: "Available at", render: (p) => <span className="tnum text-xs">{fmtTs(p.available_at)}</span> },210                  { key: "metrics", header: "Metrics", num: true, render: (p) => fmtInt(p.metrics) },211                  { key: "max_revision", header: "Max rev", num: true },212                  { key: "restated", header: "Restated", render: (p) => (p.restated ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },213                ]}214                empty="No periods."215              />216            </Card>217          )}218219          {tab === "earnings" && (220            <Card title="Earnings events">221              <DataTable222                rows={periods?.earnings}223                rowKey={(e) => e.event_date}224                mobile="scroll"225                maxHeight="60dvh"226                columns={[227                  { key: "event_date", header: "Date", render: (e) => <span className="tnum">{e.event_date}</span> },228                  { key: "release_time", header: "Release", render: (e) => e.release_time ?? "—" },229                  { key: "eps_estimate", header: "EPS est.", num: true, render: (e) => fmtNum(e.eps_estimate, 2) },230                  { key: "eps_reported", header: "EPS rep.", num: true, render: (e) => fmtNum(e.eps_reported, 2) },231                  { key: "surprise", header: "Surprise", num: true, render: (e) => (e.eps_estimate !== null && e.eps_reported !== null ? fmtNum(e.eps_reported - e.eps_estimate, 2) : "—") },232                ]}233                empty="No earnings events."234              />235            </Card>236          )}237        </>238      )}239    </>240  );241}242243function fmtValue(f: Fact): string {244  if (f.value === null || f.value === undefined) return "—";245  const u = (f.unit ?? "").toLowerCase();246  if (u.includes("ratio") || u.includes("per_share") || u === "pct" || u === "percent" || Math.abs(f.value) < 1000) return fmtNum(f.value, 2);247  return fmtCompact(f.value);248}249
Files · a5f98ef8a13b9f73ec8d98afb905553dc1fd27ed
Menu

Theme