import { mean, median, quantile, round } from '@rareindex/shared'; /** * Days on market and time-to-liquidate (§24, §30, §213–§214). * * Inputs are observed listing lifecycles: first seen → last seen, with the outcome the connector * reported. Only `sold` is a sale outcome; `ended` / `removed` are censored (withdrawn or expired) and * are reported separately, never counted as time-to-sale. Everything here is a MODEL ESTIMATE from * observed listings — it is labelled as such by the callers and is not advice. */ export type LifecycleOutcome = 'sold' | 'ended' | 'removed'; export interface ListingLifecycle { firstSeen: Date; lastSeen: Date; outcome: LifecycleOutcome; /** ask / RIV at the time (1 = at valuation); undefined when no valuation was available */ askToRiv?: number | null; } export type AskBand = 'le90' | 'b90_100' | 'b100_110' | 'gt110'; export const ASK_BANDS: ReadonlyArray<{ key: AskBand; label: string; lo: number; hi: number; /** representative ask/RIV ratio used to derive a price */ ratio: number }> = [ { key: 'le90', label: '≤ 90 % of RIV', lo: 0, hi: 0.9, ratio: 0.9 }, { key: 'b90_100', label: '90 – 100 %', lo: 0.9, hi: 1.0, ratio: 1.0 }, { key: 'b100_110', label: '100 – 110 %', lo: 1.0, hi: 1.1, ratio: 1.1 }, { key: 'gt110', label: '> 110 %', lo: 1.1, hi: Infinity, ratio: 1.1 }, ]; export const MIN_BAND_OBSERVATIONS = 5; export interface DaysOnMarket { n: number; median: number | null; mean: number | null; p25: number | null; p75: number | null; } export interface BandEstimate { band: AskBand; label: string; /** sold lifecycles in this band */ n: number; /** median days to sale; null when n < MIN_BAND_OBSERVATIONS ("Not enough data") */ medianDays: number | null; } export interface LiquidationModel { sold: DaysOnMarket; /** censored lifecycles (ended / removed without a sale) */ withdrawn: DaysOnMarket; bands: BandEstimate[]; /** share of lifecycles that ended in a sale, null without evidence */ sellThrough: number | null; } const DAY = 86_400_000; const days = (l: ListingLifecycle) => Math.max(0, (l.lastSeen.getTime() - l.firstSeen.getTime()) / DAY); function summary(ds: number[]): DaysOnMarket { const r = (v: number | null) => (v === null ? null : round(v, 1)); return { n: ds.length, median: r(median(ds)), mean: r(mean(ds)), p25: r(quantile(ds, 0.25)), p75: r(quantile(ds, 0.75)) }; } export function bandFor(askToRiv: number): AskBand { return (ASK_BANDS.find((b) => askToRiv > b.lo && askToRiv <= b.hi) ?? ASK_BANDS[ASK_BANDS.length - 1]!).key; } export function liquidationModel(lifecycles: ListingLifecycle[]): LiquidationModel { const valid = lifecycles.filter((l) => l.lastSeen.getTime() >= l.firstSeen.getTime()); const sold = valid.filter((l) => l.outcome === 'sold'); const withdrawn = valid.filter((l) => l.outcome !== 'sold'); const bands: BandEstimate[] = ASK_BANDS.map((b) => { const ds = sold.filter((l) => l.askToRiv !== null && l.askToRiv !== undefined && l.askToRiv > 0 && bandFor(l.askToRiv) === b.key).map(days); return { band: b.key, label: b.label, n: ds.length, medianDays: ds.length >= MIN_BAND_OBSERVATIONS ? round(median(ds)!, 1) : null }; }); return { sold: summary(sold.map(days)), withdrawn: summary(withdrawn.map(days)), bands, sellThrough: valid.length ? round(sold.length / valid.length, 4) : null, }; } export interface FairPrices { /** aggressive buy = RIV low band; fair buy = RIV */ buyAggressive: number | null; buyFair: number | null; /** fast sale: max(low, price of the ask band with the shortest observed median days); typical = RIV; patient = high */ sellFast: number | null; sellFastDays: number | null; sellTypical: number | null; sellTypicalDays: number | null; sellPatient: number | null; sellPatientDays: number | null; } /** * Fair buy / sell prices (§213–§214) derived ONLY from the valuation band and the observed time-to-sale * by ask band. Any component without evidence is null. */ export function fairPrices(riv: { riv: number | null; low: number | null; high: number | null }, model: LiquidationModel | null): FairPrices { 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 }; if (!riv.riv || !model) return out; const known = model.bands.filter((b) => b.medianDays !== null); if (!known.length) return out; const fastest = [...known].sort((a, b) => a.medianDays! - b.medianDays!)[0]!; const def = ASK_BANDS.find((b) => b.key === fastest.band)!; out.sellFast = round(Math.max(riv.low ?? 0, riv.riv * def.ratio), 2); out.sellFastDays = fastest.medianDays; out.sellTypicalDays = model.bands.find((b) => b.band === 'b90_100')?.medianDays ?? model.bands.find((b) => b.band === 'b100_110')?.medianDays ?? null; out.sellPatientDays = model.bands.find((b) => b.band === 'gt110')?.medianDays ?? null; return out; }