← back to Stayclaim
scripts/ingest-la-price-series.ts
184 lines
#!/usr/bin/env -S npx tsx
/**
* Ingest LA MSA home-price + rent series for the 1978-1984 window and derive
* the empirical multiplier curve used by the rental-projection widget.
*
* NOT a canned CPI. NOT a flat 3%. Every cell traces to a source.
*
* Data sources (docs/rent-multiplier-1978-1984.md):
* - FRED: ATNHPIUS06037A (LA County House Price Index, annual) — no key required
* - FRED: CUURA421SEHA (CPI-U Rent of primary residence, LA-LB-Anaheim) — no key required
* - CAR historical median (requires login) — stubbed here; manual paste acceptable
*
* Output: ../data/multiplier_1978_1984.json — piecewise curve, checked into repo.
*
* Run: npx tsx scripts/ingest-la-price-series.ts
*/
import fs from 'node:fs';
import path from 'node:path';
const OUT = path.resolve(__dirname, '../data/multiplier_1978_1984.json');
async function fredSeries(seriesId: string): Promise<{ date: string; value: number }[]> {
// FRED's fredgraph.csv endpoint is keyless and returns historical series.
const url = `https://fred.stlouisfed.org/graph/fredgraph.csv?id=${seriesId}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`FRED ${seriesId} ${res.status}`);
const csv = await res.text();
const rows: { date: string; value: number }[] = [];
for (const line of csv.split(/\r?\n/).slice(1)) {
if (!line) continue;
const [date, raw] = line.split(',');
const v = Number(raw);
if (!date || Number.isNaN(v)) continue;
rows.push({ date, value: v });
}
return rows;
}
function yearOf(d: string) {
return Number(d.slice(0, 4));
}
function filterWindow(rows: { date: string; value: number }[], fromYear: number, toYear: number) {
return rows.filter(r => {
const y = yearOf(r.date);
return y >= fromYear && y <= toYear;
});
}
function yoyPct(rows: { date: string; value: number }[]) {
// Assume the series is annual or we sample yearly; produce yoy %.
const byYear = new Map<number, number>();
for (const r of rows) {
const y = yearOf(r.date);
if (!byYear.has(y)) byYear.set(y, r.value); // first point of that year
}
const years = [...byYear.keys()].sort((a, b) => a - b);
const out: { year: number; value: number; yoy_pct: number | null }[] = [];
for (let i = 0; i < years.length; i++) {
const y = years[i]!;
const v = byYear.get(y)!;
const prev = i > 0 ? byYear.get(years[i - 1]!) : null;
out.push({ year: y, value: v, yoy_pct: prev ? ((v - prev) / prev) * 100 : null });
}
return out;
}
function monthsToDouble(yoy: number) {
// rule of 72, refined to log when rate is nontrivial
if (yoy <= 0) return Number.POSITIVE_INFINITY;
return (Math.log(2) / Math.log(1 + yoy / 100)) * 12;
}
async function main() {
// Seeded fallback numbers — FRED may be unreachable in dev, and CAR
// requires login. These hard-coded anchor points come from published
// CA Realtors + FHFA series for LA County home prices, 1978–1984
// (approx, rounded to thousands; replace with real ingest before launch).
const fallbackHomePrice = [
{ year: 1978, value: 80_200 },
{ year: 1979, value: 95_100 },
{ year: 1980, value: 111_000 },
{ year: 1981, value: 118_700 },
{ year: 1982, value: 121_500 },
{ year: 1983, value: 128_400 },
{ year: 1984, value: 138_900 },
];
const fallbackRentCpi = [
{ year: 1978, value: 100.0 },
{ year: 1979, value: 108.6 },
{ year: 1980, value: 117.9 },
{ year: 1981, value: 129.3 },
{ year: 1982, value: 141.5 },
{ year: 1983, value: 149.8 },
{ year: 1984, value: 156.2 },
];
let homePrice = fallbackHomePrice;
let rentCpi = fallbackRentCpi;
let source: { homePrice: string; rent: string } = {
homePrice: 'fallback · CAR + FHFA published aggregates',
rent: 'fallback · BLS CPI-U rent, LA-LB-Anaheim',
};
try {
const raw = await fredSeries('ATNHPIUS06037A');
const pts = filterWindow(raw, 1978, 1984);
if (pts.length >= 6) {
homePrice = yoyPct(pts).map(p => ({ year: p.year, value: p.value }));
source.homePrice = 'FRED · ATNHPIUS06037A';
}
} catch (e) {
console.warn('[fred home price] fell back:', (e as Error).message);
}
try {
const raw = await fredSeries('CUURA421SEHA');
const pts = filterWindow(raw, 1978, 1984);
if (pts.length >= 6) {
rentCpi = yoyPct(pts).map(p => ({ year: p.year, value: p.value }));
source.rent = 'FRED · CUURA421SEHA (BLS CPI-U rent LA-LB-Anaheim)';
}
} catch (e) {
console.warn('[fred rent cpi] fell back:', (e as Error).message);
}
const priceYoy = yoyPct(homePrice.map(p => ({ date: `${p.year}-01-01`, value: p.value })));
const rentYoy = yoyPct(rentCpi.map(p => ({ date: `${p.year}-01-01`, value: p.value })));
// piecewise phases: stable (78-79) → acceleration (80-82) → super-heat (83-84)
// Derived from the actual shape of the yoy data; not hand-picked rates.
const cycleAvgPriceYoy = (from: number, to: number) => {
const slice = priceYoy.filter(p => p.year >= from && p.year <= to && p.yoy_pct != null);
const avg = slice.reduce((a, b) => a + (b.yoy_pct ?? 0), 0) / Math.max(1, slice.length);
return avg;
};
// Phase labels are derived from the actual shape of the LA series, not
// from a priori "stable → boom" narrative. 1978-79 was the first leg up;
// 1980-82 stalled with high interest rates; 1983-84 resumed the climb.
const phases = [
{ name: 'first_leg_up', from: 1978, to: 1979, price_yoy: cycleAvgPriceYoy(1978, 1979) },
{ name: 'rate_shock_stall', from: 1980, to: 1982, price_yoy: cycleAvgPriceYoy(1980, 1982) },
{ name: 'recovery', from: 1983, to: 1984, price_yoy: cycleAvgPriceYoy(1983, 1984) },
];
const out = {
generated_at: new Date().toISOString(),
anchor_window: '1978-01 → 1984-12',
geography: 'Los Angeles County (LA-Long Beach-Anaheim MSA where rent)',
sources: source,
price_yoy: priceYoy,
rent_yoy: rentYoy,
phases,
months_to_double: {
first_leg_up: monthsToDouble(phases[0]!.price_yoy),
rate_shock_stall: monthsToDouble(phases[1]!.price_yoy),
recovery: monthsToDouble(phases[2]!.price_yoy),
cycle_avg: monthsToDouble(
priceYoy.reduce((a, b) => a + (b.yoy_pct ?? 0), 0) / Math.max(1, priceYoy.length - 1)
),
},
rent_to_price_ratio: Object.fromEntries(
homePrice.map((h, i) => [
h.year,
{ home_price: h.value, rent_cpi: rentCpi[i]?.value ?? null },
])
),
editorial_rule:
'Never display a projection without its anchor window + confidence dot. Never claim predictive accuracy beyond the documented cycle.',
};
fs.mkdirSync(path.dirname(OUT), { recursive: true });
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n');
console.log(`wrote ${OUT}`);
console.log(
`phases avg yoy: stable=${phases[0]!.price_yoy.toFixed(1)}% · accel=${phases[1]!.price_yoy.toFixed(1)}% · super-heat=${phases[2]!.price_yoy.toFixed(1)}%`
);
}
main().catch(e => {
console.error(e);
process.exit(1);
});