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%
5.0 KB · 114 lines typescript
Raw Blame History
1import { mean, median, quantile, round } from '@rareindex/shared';23/**4 * Days on market and time-to-liquidate (§24, §30, §213–§214).5 *6 * Inputs are observed listing lifecycles: first seen → last seen, with the outcome the connector7 * reported. Only `sold` is a sale outcome; `ended` / `removed` are censored (withdrawn or expired) and8 * are reported separately, never counted as time-to-sale. Everything here is a MODEL ESTIMATE from9 * observed listings — it is labelled as such by the callers and is not advice.10 */11export type LifecycleOutcome = 'sold' | 'ended' | 'removed';1213export interface ListingLifecycle {14  firstSeen: Date;15  lastSeen: Date;16  outcome: LifecycleOutcome;17  /** ask / RIV at the time (1 = at valuation); undefined when no valuation was available */18  askToRiv?: number | null;19}2021export type AskBand = 'le90' | 'b90_100' | 'b100_110' | 'gt110';22export const ASK_BANDS: ReadonlyArray<{ key: AskBand; label: string; lo: number; hi: number; /** representative ask/RIV ratio used to derive a price */ ratio: number }> = [23  { key: 'le90', label: '≤ 90 % of RIV', lo: 0, hi: 0.9, ratio: 0.9 },24  { key: 'b90_100', label: '90 – 100 %', lo: 0.9, hi: 1.0, ratio: 1.0 },25  { key: 'b100_110', label: '100 – 110 %', lo: 1.0, hi: 1.1, ratio: 1.1 },26  { key: 'gt110', label: '> 110 %', lo: 1.1, hi: Infinity, ratio: 1.1 },27];28export const MIN_BAND_OBSERVATIONS = 5;2930export interface DaysOnMarket {31  n: number;32  median: number | null;33  mean: number | null;34  p25: number | null;35  p75: number | null;36}3738export interface BandEstimate {39  band: AskBand;40  label: string;41  /** sold lifecycles in this band */42  n: number;43  /** median days to sale; null when n < MIN_BAND_OBSERVATIONS ("Not enough data") */44  medianDays: number | null;45}4647export interface LiquidationModel {48  sold: DaysOnMarket;49  /** censored lifecycles (ended / removed without a sale) */50  withdrawn: DaysOnMarket;51  bands: BandEstimate[];52  /** share of lifecycles that ended in a sale, null without evidence */53  sellThrough: number | null;54}5556const DAY = 86_400_000;57const days = (l: ListingLifecycle) => Math.max(0, (l.lastSeen.getTime() - l.firstSeen.getTime()) / DAY);5859function summary(ds: number[]): DaysOnMarket {60  const r = (v: number | null) => (v === null ? null : round(v, 1));61  return { n: ds.length, median: r(median(ds)), mean: r(mean(ds)), p25: r(quantile(ds, 0.25)), p75: r(quantile(ds, 0.75)) };62}6364export function bandFor(askToRiv: number): AskBand {65  return (ASK_BANDS.find((b) => askToRiv > b.lo && askToRiv <= b.hi) ?? ASK_BANDS[ASK_BANDS.length - 1]!).key;66}6768export function liquidationModel(lifecycles: ListingLifecycle[]): LiquidationModel {69  const valid = lifecycles.filter((l) => l.lastSeen.getTime() >= l.firstSeen.getTime());70  const sold = valid.filter((l) => l.outcome === 'sold');71  const withdrawn = valid.filter((l) => l.outcome !== 'sold');72  const bands: BandEstimate[] = ASK_BANDS.map((b) => {73    const ds = sold.filter((l) => l.askToRiv !== null && l.askToRiv !== undefined && l.askToRiv > 0 && bandFor(l.askToRiv) === b.key).map(days);74    return { band: b.key, label: b.label, n: ds.length, medianDays: ds.length >= MIN_BAND_OBSERVATIONS ? round(median(ds)!, 1) : null };75  });76  return {77    sold: summary(sold.map(days)),78    withdrawn: summary(withdrawn.map(days)),79    bands,80    sellThrough: valid.length ? round(sold.length / valid.length, 4) : null,81  };82}8384export interface FairPrices {85  /** aggressive buy = RIV low band; fair buy = RIV */86  buyAggressive: number | null;87  buyFair: number | null;88  /** fast sale: max(low, price of the ask band with the shortest observed median days); typical = RIV; patient = high */89  sellFast: number | null;90  sellFastDays: number | null;91  sellTypical: number | null;92  sellTypicalDays: number | null;93  sellPatient: number | null;94  sellPatientDays: number | null;95}9697/**98 * Fair buy / sell prices (§213–§214) derived ONLY from the valuation band and the observed time-to-sale99 * by ask band. Any component without evidence is null.100 */101export function fairPrices(riv: { riv: number | null; low: number | null; high: number | null }, model: LiquidationModel | null): FairPrices {102  const out: FairPrices = { buyAggressive: riv.low ?? null, buyFair: riv.riv ?? null, sellFast: null, sellFastDays: null, sellTypical: riv.riv ?? null, sellTypicalDays: null, sellPatient: riv.high ?? null, sellPatientDays: null };103  if (!riv.riv || !model) return out;104  const known = model.bands.filter((b) => b.medianDays !== null);105  if (!known.length) return out;106  const fastest = [...known].sort((a, b) => a.medianDays! - b.medianDays!)[0]!;107  const def = ASK_BANDS.find((b) => b.key === fastest.band)!;108  out.sellFast = round(Math.max(riv.low ?? 0, riv.riv * def.ratio), 2);109  out.sellFastDays = fastest.medianDays;110  out.sellTypicalDays = model.bands.find((b) => b.band === 'b90_100')?.medianDays ?? model.bands.find((b) => b.band === 'b100_110')?.medianDays ?? null;111  out.sellPatientDays = model.bands.find((b) => b.band === 'gt110')?.medianDays ?? null;112  return out;113}114