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%
8.7 KB · 165 lines tsx
Raw Blame History
1import type { AssetDepth, AssetLiquidation } from '@/lib/queries/depth';2import { fmtMoney, fmtNum, fmtPct, cn } from '@/lib/format';3import { Card, CardHeader, Table, th, td, tdNum, Badge, Unavailable, VsRiv } from '@/components/ui/primitives';45const fmtDays = (d: number | null) => (d === null ? null : d < 1 ? '< 1 day' : `~${Math.round(d)} day${Math.round(d) === 1 ? '' : 's'}`);67/**8 * Market depth (§25), RareIndex spread (§26), days on market (§30), time-to-liquidate (§24) and the9 * fair buy / sell ladder (§213–§214). Server-rendered; every number comes from observed listings and10 * the published valuation band, and missing evidence is shown as such.11 */12export function DepthLiquidationCard({ depth, liquidation, riv, variantLabel }: { depth: AssetDepth | null; liquidation: AssetLiquidation | null; riv: number | null; variantLabel: string | null }) {13  const d = depth?.depth ?? null;14  const m = liquidation?.model ?? null;15  const f = liquidation?.fair ?? null;16  const bands = d17    ? [18        { label: '±5 %', n: d.within5 },19        { label: '±10 %', n: d.within10 },20        { label: '±20 %', n: d.within20 },21      ]22    : [];23  const max = d ? Math.max(1, d.asks) : 1;24  return (25    <Card>26      <CardHeader27        title="Market depth & time to sell"28        subtitle={`Current asks around the valuation${depth?.scope === 'variant' ? ` of ${variantLabel ?? 'the representative variant'}` : ''}, observed days on market and a time-to-sale model by asking band. Model analytics from observed listings and sales, not advice.`}29      />30      <div className="grid gap-4 p-4 lg:grid-cols-3">31        {/* Depth */}32        <section>33          <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Market depth</h3>34          {d && riv !== null ? (35            <>36              <dl className="mt-2 grid grid-cols-3 gap-2 text-[12px]">37                <div>38                  <dt className="text-[10px] uppercase tracking-wider text-subtle">Asks</dt>39                  <dd className="num font-medium text-fg">{fmtNum(d.asks)}</dd>40                </div>41                <div>42                  <dt className="text-[10px] uppercase tracking-wider text-subtle">Below RIV</dt>43                  <dd className="num font-medium text-fg">{fmtNum(d.belowRiv)}</dd>44                </div>45                <div>46                  <dt className="text-[10px] uppercase tracking-wider text-subtle">Above RIV</dt>47                  <dd className="num font-medium text-fg">{fmtNum(d.aboveRiv)}</dd>48                </div>49              </dl>50              <ul className="mt-3 grid gap-1.5">51                {bands.map((b) => (52                  <li key={b.label} className="grid grid-cols-[52px_1fr_36px] items-center gap-2 text-[11px]">53                    <span className="num text-muted">{b.label}</span>54                    <span className="h-2 overflow-hidden rounded-sm bg-inset" aria-hidden>55                      <span className="block h-full bg-index" style={{ width: `${Math.round((100 * b.n) / max)}%` }} />56                    </span>57                    <span className="num text-right text-fg">{b.n}</span>58                  </li>59                ))}60              </ul>61              <div className="mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-[12px]">62                <span>63                  <span className="text-[10px] uppercase tracking-wider text-subtle">Best ask </span>64                  <span className="num font-medium">{d.lowestAsk === null ? <Unavailable reason="No active asks" /> : fmtMoney(d.lowestAsk)}</span>65                </span>66                <span>67                  <span className="text-[10px] uppercase tracking-wider text-subtle">Median ask </span>68                  <span className="num font-medium">{d.medianAsk === null ? <Unavailable reason="No active asks" /> : fmtMoney(d.medianAsk)}</span>69                </span>70                <span title="RareIndex spread: best ask versus the valuation of the same variant">71                  <span className="text-[10px] uppercase tracking-wider text-subtle">Spread </span>72                  <VsRiv discount={d.askRivSpread} className="text-[12px]" />73                </span>74              </div>75            </>76          ) : (77            <p className="mt-2 text-[12px] text-muted">Not enough data — depth needs a transaction-based valuation for this variant.</p>78          )}79        </section>8081        {/* Days on market + bands */}82        <section>83          <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">84            Days on market{' '}85            {liquidation ? (86              <Badge tone={liquidation.level === 'category' ? 'alert' : 'neutral'} className="ml-1 normal-case tracking-normal">87                Model estimate · {liquidation.level === 'category' ? 'category-level' : 'this asset'}88              </Badge>89            ) : null}90          </h3>91          {m ? (92            <>93              <dl className="mt-2 grid grid-cols-4 gap-2 text-[12px]">94                {[95                  ['Sold', m.sold.n === 0 ? null : fmtNum(m.sold.n)],96                  ['Median', fmtDays(m.sold.median)],97                  ['P25 – P75', m.sold.p25 === null || m.sold.p75 === null ? null : `${Math.round(m.sold.p25)} – ${Math.round(m.sold.p75)} d`],98                  ['Sell-through', m.sellThrough === null ? null : fmtPct(m.sellThrough, 0, false)],99                ].map(([k, v]) => (100                  <div key={k as string}>101                    <dt className="text-[10px] uppercase tracking-wider text-subtle">{k}</dt>102                    <dd className="num font-medium text-fg">{v ?? <Unavailable reason="Not enough data" />}</dd>103                  </div>104                ))}105              </dl>106              <p className="mt-1 text-[11px] text-subtle">107                {fmtNum(m.withdrawn.n)} withdrawn/expired without a sale{m.withdrawn.median !== null ? ` (median ${fmtDays(m.withdrawn.median)} listed)` : ''} · {fmtNum(liquidation!.lifecycles)} lifecycles observed108              </p>109              <Table className="mt-3">110                <thead>111                  <tr>112                    <th className={th}>Asking band</th>113                    <th className={cn(th, 'text-right')}>Sold</th>114                    <th className={cn(th, 'text-right')}>Median time to sale</th>115                  </tr>116                </thead>117                <tbody>118                  {m.bands.map((b) => (119                    <tr key={b.band}>120                      <td className={cn(td, 'text-muted')}>{b.label}</td>121                      <td className={tdNum}>{b.n}</td>122                      <td className={tdNum}>{b.medianDays === null ? <span className="text-subtle" title="Fewer than 5 sold lifecycles in this band">Not enough data</span> : fmtDays(b.medianDays)}</td>123                    </tr>124                  ))}125                </tbody>126              </Table>127            </>128          ) : (129            <p className="mt-2 text-[12px] text-muted">Not enough data — no completed listing lifecycles observed for this asset or its category yet.</p>130          )}131        </section>132133        {/* Fair buy / sell ladder */}134        <section>135          <h3 className="text-[11px] font-semibold uppercase tracking-wider text-subtle">Fair buy · fair sell</h3>136          {riv !== null && f ? (137            <Table className="mt-2">138              <tbody>139                {[140                  ['Aggressive buy', f.buyAggressive, null, 'RIV low band'],141                  ['Fair buy', f.buyFair, null, 'RIV'],142                  ['Fast sale', f.sellFast, f.sellFastDays, 'band with the shortest observed time to sale'],143                  ['Typical sale', f.sellTypical, f.sellTypicalDays, 'RIV'],144                  ['Patient sale', f.sellPatient, f.sellPatientDays, 'RIV high band'],145                ].map(([k, v, days, basis]) => (146                  <tr key={k as string}>147                    <td className={cn(td, 'text-muted')} title={`Basis: ${basis}`}>148                      {k}149                    </td>150                    <td className={cn(tdNum, 'font-medium')}>{v === null || v === undefined ? <Unavailable reason="Not enough data" /> : fmtMoney(v as number)}</td>151                    <td className={cn(tdNum, 'text-[11px] text-subtle')}>{days === null || days === undefined ? '' : fmtDays(days as number)}</td>152                  </tr>153                ))}154              </tbody>155            </Table>156          ) : (157            <p className="mt-2 text-[12px] text-muted">Not enough data — the ladder needs a published valuation.</p>158          )}159          <p className="mt-2 text-[11px] text-subtle">Prices come from the RIV low/high band; days from the observed asking bands above. Model analytics, not advice.</p>160        </section>161      </div>162    </Card>163  );164}165