← back to Nationalrealestate
src/ingest/fhfa_hpi.ts
152 lines
/**
* FHFA House Price Index (HPI_master.csv) -> metric 'hpi', quarterly, metro + state.
* URL DRIFT (probed 2026-07-21): the old /DataTools/Downloads/Documents/HPI/HPI_master.csv
* 404s; current path is /hpi/download/monthly/hpi_master.csv (serves the full master file).
* Flavor choice: purchase-only quarterly covers only 100 MSAs vs 410 for all-transactions,
* so we use hpi_type='traditional', hpi_flavor='all-transactions', frequency='quarterly'.
* Metro match: place_id IS the CBSA code -> region.cbsa_code. State match: 2-letter place_id.
* Last 12 quarters only; period = quarter-end date; value = index_nsa (SA blank for many MSAs).
*/
import { readFileSync } from 'node:fs';
import { pool } from '../../db/pool.ts';
import { openRun, closeRun, download } from './run.ts';
import { loadRegionMaps, loadOverrides, buildMetroMatcher, upsertMetricRows, upsertSourceRegionMap, UnmatchedCollector, type MetricRow } from './crosswalk.ts';
const SOURCE = 'fhfa';
const URL = 'https://www.fhfa.gov/hpi/download/monthly/hpi_master.csv';
const QUARTERS_KEPT = 12;
function parseCsvLine(line: string): string[] {
const out: string[] = [];
let cur = '', inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQ) {
if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
else cur += c;
} else if (c === '"') inQ = true;
else if (c === ',') { out.push(cur); cur = ''; }
else cur += c;
}
out.push(cur);
return out;
}
const QUARTER_END: Record<string, string> = { '1': '03-31', '2': '06-30', '3': '09-30', '4': '12-31' };
async function main() {
const runId = await openRun(SOURCE, URL);
try {
const { path, sha256 } = await download(URL, 'hpi_master.csv');
const lines = readFileSync(path, 'utf8').split('\n');
const header = parseCsvLine(lines[0].trim());
const idx = (name: string) => {
const i = header.indexOf(name);
if (i < 0) throw new Error(`HPI header missing ${name}: ${header.join(',')}`);
return i;
};
const iType = idx('hpi_type'), iFlavor = idx('hpi_flavor'), iFreq = idx('frequency'),
iLevel = idx('level'), iName = idx('place_name'), iId = idx('place_id'),
iYr = idx('yr'), iPeriod = idx('period'), iNsa = idx('index_nsa');
type Parsed = { level: string; placeId: string; placeName: string; yr: number; q: string; value: number };
const parsed: Parsed[] = [];
let maxYr = 0;
for (let n = 1; n < lines.length; n++) {
const line = lines[n].trim();
if (!line) continue;
const c = parseCsvLine(line);
if (c[iType] !== 'traditional' || c[iFlavor] !== 'all-transactions' || c[iFreq] !== 'quarterly') continue;
const level = c[iLevel];
if (level !== 'MSA' && level !== 'State') continue;
const value = Number(c[iNsa]);
if (!Number.isFinite(value)) continue;
const yr = Number(c[iYr]);
parsed.push({ level, placeId: c[iId], placeName: c[iName], yr, q: c[iPeriod], value });
if (yr > maxYr) maxYr = yr;
}
if (parsed.length === 0) throw new Error('HPI parse produced 0 rows — format drift?');
// keep last 12 quarters: quarter index yr*4 + q
const latestQ = Math.max(...parsed.filter(p => p.yr === maxYr).map(p => Number(p.q)));
const latestIdx = maxYr * 4 + latestQ;
const cutoffIdx = latestIdx - (QUARTERS_KEPT - 1);
const maps = await loadRegionMaps();
const overrides = loadOverrides(SOURCE);
const matchMetro = buildMetroMatcher(maps);
const unmatched = new UnmatchedCollector();
// regions covered by a DIRECT cbsa_code match — an MSAD fallback must never overwrite these
const directRegionIds = new Set<number>();
for (const p of parsed) {
if (p.level !== 'MSA') continue;
const direct = maps.byCbsa.get(p.placeId);
if (direct) directRegionIds.add(direct.id);
}
/** "Los Angeles-Long Beach-Glendale, CA (MSAD)" -> parent CBSA via first city × each state token. */
const matchMsad = (placeName: string) => {
const name = placeName.replace(/\s*\(MSAD\)$/, '');
const comma = name.lastIndexOf(',');
if (comma < 0) return null;
const firstCity = name.slice(0, comma).split(/[-/]/)[0].trim();
for (const st of name.slice(comma + 1).trim().split('-')) {
const m = matchMetro(`${firstCity}, ${st.trim()}`);
if (m) return m;
}
return null;
};
const rows: MetricRow[] = [];
const mapEntries: { source: string; sourceRegionId: string; regionId: number; matchMethod: string }[] = [];
const unmatchedIds = new Set<string>();
for (const p of parsed) {
if (p.yr * 4 + Number(p.q) < cutoffIdx) continue;
let region = p.level === 'MSA' ? maps.byCbsa.get(p.placeId) : maps.byStateCode.get(p.placeId);
let method = p.level === 'MSA' ? 'cbsa' : 'state';
if (!region && overrides[p.placeId]) {
region = maps.byCanonicalKey.get(overrides[p.placeId]);
method = 'override';
}
if (!region && p.level === 'MSA' && p.placeName.endsWith('(MSAD)')) {
// FHFA lists divisions INSTEAD of the parent CBSA for the largest metros.
// The PRIMARY division's first-named city equals the parent CBSA's first city
// ("Los Angeles-Long Beach-Glendale, CA" -> CBSA 31080); secondary divisions
// (Anaheim, Camden, Cambridge...) correctly fail the prefix match and stay skipped.
// Never applied to a region already covered by a direct cbsa_code match.
const m = matchMsad(p.placeName);
if (m && !directRegionIds.has(m.region.id)) { region = m.region; method = 'msad_primary_city'; }
}
if (!region) {
if (!unmatchedIds.has(p.placeId)) {
unmatchedIds.add(p.placeId);
unmatched.add(SOURCE, { level: p.level, place_id: p.placeId, place_name: p.placeName });
}
continue;
}
rows.push({
regionId: region.id, source: SOURCE, metric: 'hpi',
period: `${p.yr}-${QUARTER_END[p.q]}`, value: p.value,
});
mapEntries.push({ source: SOURCE, sourceRegionId: p.placeId, regionId: region.id, matchMethod: method });
}
if (rows.length === 0) throw new Error('HPI matched 0 regions — crosswalk drift?');
const upserted = await upsertMetricRows(rows, runId);
await upsertSourceRegionMap(mapEntries);
unmatched.flush();
await closeRun(runId, 'ok', {
upserted, skipped: unmatchedIds.size, fileHash: sha256,
notes: `flavor=all-transactions latest=${maxYr}Q${latestQ} quarters=${QUARTERS_KEPT} unmatched_places=${unmatchedIds.size}`,
});
console.log(`[fhfa] ok: ${upserted} rows (latest ${maxYr}Q${latestQ}), ${unmatchedIds.size} unmatched place_ids`);
} catch (e) {
await closeRun(runId, 'failed', { notes: String(e) });
console.error('[fhfa] FAILED:', e);
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();