SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.4 KB · 71 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { requireUser } from '@/lib/auth/session';4import { indexSeries, listCollections, loadItems, portfolioHistory } from '@/lib/account/queries';5import { rebase, summarizePortfolio } from '@/lib/account/portfolio';6import { getDisplay } from '@/lib/account/display';7import { INDICES, indexForCategory } from '@rareindex/taxonomy';8import { PageHeader, btnSecondary } from '@/components/account/page-header';9import { LineChart } from '@/components/account/charts';10import { Card, CardHeader, Delta, Stat } from '@/components/ui/primitives';11import { pctChange, volatility, maxDrawdown } from '@rareindex/shared';12import { fmtPct } from '@/lib/format';1314export const metadata: Metadata = { title: 'My Index', robots: { index: false } };1516/** Personal index: the member's portfolio value rebased to 1000, against RARE and the relevant subindices. */17export default async function MyIndexPage() {18  const u = await requireUser('/my-index');19  const d = await getDisplay();20  const cols = await listCollections(u.id);21  const items = await loadItems(cols.map((c) => c.collection.id));22  const summary = summarizePortfolio(items);23  const history = await portfolioHistory(u.id);24  const since = history[0]?.date;25  const mine = rebase(history.map((h) => ({ date: h.date, value: h.valueUsd })));26  const tickers = ['RARE', ...new Set(summary.allocationByCategory.map((b) => indexForCategory(b.key)?.ticker).filter((t): t is string => Boolean(t)))].slice(0, 5);27  const bench = await Promise.all(tickers.map(async (t) => ({ ticker: t, points: rebase(await indexSeries(t, since)) })));28  const values = history.map((h) => h.valueUsd);29  const last = values[values.length - 1] ?? null;30  const nowMs = new Date().getTime();31  const at = (days: number) => {32    if (!history.length) return null;33    const target = new Date(nowMs - days * 86_400_000).toISOString().slice(0, 10);34    const p = [...history].reverse().find((h) => h.date <= target);35    return p ? pctChange(p.valueUsd, last) : null;36  };37  const vol = volatility(values, 365);38  const dd = maxDrawdown(values);3940  return (41    <>42      <PageHeader title="My Index" description="Your portfolio as an index: rebased to 1,000 on your first snapshot and compared with RARE and the subindices of the categories you hold." actions={<Link href="/collections" className={btnSecondary}>Collections</Link>} />43      <div className="mb-5 grid grid-cols-2 gap-4 sm:grid-cols-5">44        <Stat label="My index" value={mine.length ? mine[mine.length - 1]!.value.toFixed(1) : '—'} sub={history.length ? `${history.length} snapshots since ${since}` : 'no snapshots yet'} />45        <Stat label="7d" value={<Delta value={at(7)} className="text-lg" />} />46        <Stat label="30d" value={<Delta value={at(30)} className="text-lg" />} />47        <Stat label="Volatility (ann.)" value={vol === null ? '—' : fmtPct(vol, 1, false)} sub={values.length < 30 ? 'needs 30+ days' : 'from daily snapshots'} />48        <Stat label="Max drawdown" value={dd === null ? '—' : fmtPct(-dd, 1)} />49      </div>50      <Card>51        <CardHeader title="Rebased performance" subtitle={`Portfolio value in ${d.currency} vs indices · both rebased to 1,000`} />52        <div className="p-4">53          <LineChart series={[{ name: 'My index', points: mine }, ...bench.filter((b) => b.points.length > 1).map((b, i) => ({ name: b.ticker, points: b.points, color: INDICES.find((x) => x.ticker === b.ticker)?.color ?? `var(--ri-fg-subtle)`, dashed: i > 0 }))]} height={260} formatY={(v) => v.toFixed(0)} />54          <div className="mt-3 flex flex-wrap gap-4 text-xs text-muted">55            <span>56              <span className="mr-1 inline-block h-2 w-3 rounded-sm bg-index align-middle" /> My index57            </span>58            {bench.map((b) => (59              <span key={b.ticker}>60                <span className="mr-1 inline-block h-2 w-3 rounded-sm align-middle" style={{ background: INDICES.find((x) => x.ticker === b.ticker)?.color ?? 'var(--ri-fg-subtle)' }} /> {b.ticker}61                {b.points.length <= 1 ? <span className="text-subtle"> (no history yet)</span> : null}62              </span>63            ))}64          </div>65          <p className="mt-3 text-[11px] text-subtle">Your index reflects valuation changes and additions/removals; it is not a time-weighted return. Index values are published only when enough constituents are priced.</p>66        </div>67      </Card>68    </>69  );70}71