TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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