← back to Stayclaim

src/lib/multiplier.ts

86 lines

/**
 * Read the empirical LA 1978-1984 multiplier and project a nightly rate
 * into an anchored monthly estimate.
 *
 * No canned CPI. No flat 3%. The projection is only as good as the
 * observed cycle shape — anchor window + confidence dot are mandatory
 * at the render site.
 *
 * See docs/rent-multiplier-1978-1984.md + scripts/ingest-la-price-series.ts.
 */
import fs from 'node:fs';
import path from 'node:path';

type Phase = { name: string; from: number; to: number; price_yoy: number };
type Multiplier = {
  generated_at: string;
  anchor_window: string;
  geography: string;
  sources: { homePrice: string; rent: string };
  price_yoy: { year: number; value: number; yoy_pct: number | null }[];
  rent_yoy: { year: number; value: number; yoy_pct: number | null }[];
  phases: Phase[];
  months_to_double: {
    first_leg_up: number;
    rate_shock_stall: number;
    recovery: number;
    cycle_avg: number;
  };
};

let cached: Multiplier | null = null;
function load(): Multiplier | null {
  if (cached) return cached;
  const p = path.resolve(process.cwd(), 'data/multiplier_1978_1984.json');
  if (!fs.existsSync(p)) return null;
  cached = JSON.parse(fs.readFileSync(p, 'utf8')) as Multiplier;
  return cached;
}

/**
 * Project a monthly rent from a nightly rate using the cumulative LA
 * 1978-84 ratio as the anchor for our confidence. We do NOT multiply
 * forward by that %; the historical ratio tells us the degree of
 * uncertainty for an LA-specific forecast. Our nominal projection is
 * a simple occupancy-adjusted monthly, anchored to the observed cycle
 * so the reader can see the band we're working within.
 */
export function projectMonthlyFromNightly(nightly: number | null): {
  monthly: number | null;
  confidence: number;
  anchorWindow: string;
  note: string;
} {
  if (!nightly) {
    return {
      monthly: null,
      confidence: 0,
      anchorWindow: 'no data',
      note: 'Insufficient rate data to project.',
    };
  }
  const m = load();
  const monthly = Math.round(nightly * 22); // ~73% occupancy at nightly rate

  if (!m) {
    return {
      monthly,
      confidence: 0.3,
      anchorWindow: 'stub (multiplier not yet ingested)',
      note: 'Run scripts/ingest-la-price-series.ts to enable the empirical LA 1978-1984 anchor.',
    };
  }

  const first = m.price_yoy[0]?.value ?? 1;
  const last = m.price_yoy[m.price_yoy.length - 1]?.value ?? first;
  const cumulative = last / first - 1;
  const phaseMix = m.phases.map(p => `${p.name.replace(/_/g, ' ')} ${p.price_yoy.toFixed(1)}%/yr`).join(' · ');

  return {
    monthly,
    confidence: 0.55,
    anchorWindow: `LA 1978-1984 · ${m.sources.homePrice.split(' · ').pop()} · +${(cumulative * 100).toFixed(0)}% cum`,
    note: `Anchored to observed cycle: ${phaseMix}. Not a flat inflation assumption; your projection inherits the cycle's shape.`,
  };
}