← 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.`,
};
}