← back to Commercialrealestate

scripts/fetch-fha-loans.js

322 lines

#!/usr/bin/env node
// fetch-fha-loans.js — CRCP "FHA / low-rate loans" builder.
//
// Pulls REAL public data only ($0):
//   1. HUD/FHA Multifamily Insured Mortgages (ACTIVE) — property name, city/state/zip,
//      original mortgage amount, INTEREST RATE, initial-endorsement (origination) date.
//      This is the realistic public dataset that carries a per-loan ORIGINAL INTEREST RATE.
//      FHA SINGLE-FAMILY per-loan rate+address data is NOT public (borrower privacy), so the
//      deliverable is scoped to HUD/FHA MULTIFAMILY (commercial) and clearly labeled as such.
//   2. Freddie Mac PMMS 30-yr fixed weekly average (history back to 1971) — the market benchmark.
//
// Processing:
//   - keep loans whose ORIGINAL interest rate <= 3.5%
//   - belowMarket   = loan rate is >= 0.75 pct-pt below the PMMS 30-yr avg for its origination month
//   - historicLowEra= origination month falls inside a PMMS rock-bottom-rate trough window
//                     (derived from the data, not hardcoded)
//   - RED = belowMarket OR historicLowEra (either signal fires red); we keep both reasons.
//
// Idempotent + re-runnable. Logs real source URLs + row counts. Writes data/fha-loans.json.
//
// Run: node scripts/fetch-fha-loans.js

const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const XLSX = require('xlsx');

const ROOT = path.join(__dirname, '..');
const OUT = path.join(ROOT, 'data', 'fha-loans.json');
const RAW = path.join(ROOT, 'data', 'raw');

// ---- real public source URLs (verified reachable 2026-06-30, all $0) ----
const SRC = {
  hudActiveXlsx:
    'https://www.hud.gov/sites/default/files/Housing/documents/FHA-BF90-RM-A.xlsx',
  hudPage: 'https://www.hud.gov/hud-partners/multifamily-fhasl-active',
  // Freddie Mac PMMS 30-yr fixed weekly history (canonical origin)
  pmmsFreddie: 'https://www.freddiemac.com/pmms/docs/historicalweeklydata.xlsx',
  // FRED republishes the same PMMS 30-yr series as a clean CSV — used as the machine-readable feed
  pmmsCsv: 'https://fred.stlouisfed.org/graph/fredgraph.csv?id=MORTGAGE30US',
};

const RATE_CAP = 3.5;          // keep loans originally at or below this
const BELOW_MARKET_GAP = 0.75; // pct-pt below the month's PMMS benchmark to flag belowMarket

function log(...a) { console.log('[fha-loans]', ...a); }

// Download a URL to a Buffer via curl (follows redirects, hard timeout). Public, unauthenticated, $0.
// curl is used rather than node https because some gov/data hosts (FRED) stall node's TLS client.
function download(url) {
  const tmp = path.join(os.tmpdir(), 'crcp-dl-' + Math.random().toString(36).slice(2) + '.bin');
  try {
    // --http1.1: FRED intermittently resets HTTP/2 streams (curl err 92); HTTP/1.1 is stable.
    execFileSync(
      'curl',
      ['-sSL', '--fail', '--http1.1', '--retry', '2', '--max-time', '90',
        '-A', 'CRCP/1.0 (+local FHA loan explorer)', '-o', tmp, url],
      { stdio: ['ignore', 'ignore', 'pipe'] }
    );
    return fs.readFileSync(tmp);
  } finally {
    try { fs.unlinkSync(tmp); } catch (_) {}
  }
}

// Excel serial date -> 'YYYY-MM-DD' (HUD endorsement dates are stored as Excel serials).
function excelSerialToISO(n) {
  if (typeof n !== 'number' || !isFinite(n) || n <= 0) return null;
  const d = XLSX.SSF.parse_date_code(n);
  if (!d || !d.y) return null;
  return `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`;
}

// Build month->[rates] from the FRED CSV (observation_date,MORTGAGE30US).
function pmmsFromCsv(csv) {
  const acc = {};
  const lines = csv.trim().split('\n').slice(1); // drop header
  for (const line of lines) {
    const [date, val] = line.split(',');
    if (!date || val == null || val === '.') continue;
    const r = parseFloat(val);
    if (!isFinite(r)) continue;
    (acc[date.slice(0, 7)] = acc[date.slice(0, 7)] || []).push(r);
  }
  return { acc, rows: lines.length };
}

// Fallback: build month->[rates] from the Freddie Mac PMMS xlsx (col0=Week serial, col1=30yr FRM).
function pmmsFromXlsx(buf) {
  const _warn = console.error; console.error = () => {};
  const wb = XLSX.read(buf, { type: 'buffer' });
  console.error = _warn;
  const ws = wb.Sheets[wb.SheetNames[0]];
  const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null });
  const acc = {};
  let rowcount = 0;
  for (const r of rows) {
    if (!r) continue;
    const serial = Number(r[0]);
    const rate = Number(r[1]);
    if (!isFinite(serial) || serial < 20000 || !isFinite(rate) || rate <= 0 || rate > 25) continue;
    const iso = excelSerialToISO(serial);
    if (!iso) continue;
    (acc[iso.slice(0, 7)] = acc[iso.slice(0, 7)] || []).push(rate);
    rowcount++;
  }
  return { acc, rows: rowcount };
}

// ---- 1. PMMS benchmark: month -> avg 30yr fixed. FRED CSV primary, Freddie Mac xlsx fallback. ----
function loadPmms() {
  let acc = null, rows = 0;
  try {
    const csv = download(SRC.pmmsCsv).toString('utf8');
    ({ acc, rows } = pmmsFromCsv(csv));
    log('PMMS: fetched FRED CSV', SRC.pmmsCsv, `(${rows} weekly obs)`);
  } catch (e) {
    log('PMMS FRED CSV failed (' + e.message.split('\n')[0] + '); trying Freddie Mac xlsx…');
    try {
      const buf = download(SRC.pmmsFreddie);
      ({ acc, rows } = pmmsFromXlsx(buf));
      log('PMMS: fetched Freddie Mac xlsx', SRC.pmmsFreddie, `(${rows} weekly obs)`);
    } catch (e2) {
      log('PMMS fetch FAILED (both sources):', e2.message.split('\n')[0]);
      return { byMonth: {}, troughs: [], rows: 0 };
    }
  }
  const byMonth = {};
  for (const [m, arr] of Object.entries(acc)) {
    byMonth[m] = +(arr.reduce((s, x) => s + x, 0) / arr.length).toFixed(3);
  }
  const troughs = deriveTroughs(byMonth);
  log(`PMMS: ${Object.keys(byMonth).length} monthly benchmarks (1971→present); derived ${troughs.length} historic-low windows`);
  troughs.forEach((t) => log(`  trough window ${t.start}..${t.end} (min PMMS ${t.minRate}%)`));
  return { byMonth, troughs, rows };
}

// Derive "historic low" windows: take the months whose PMMS avg is within ~0.6 pct-pt of the
// all-time minimum, then collapse contiguous runs into [start,end] windows. Data-driven, not
// hardcoded — this naturally yields the 2020–2021 sub-3% trough and the 2012–2013 ~3.3-3.5% trough.
function deriveTroughs(byMonth) {
  const entries = Object.entries(byMonth).sort((a, b) => a[0].localeCompare(b[0]));
  if (!entries.length) return [];
  const minRate = Math.min(...entries.map((e) => e[1]));
  const THRESH = minRate + 0.85; // within 0.85 pct-pt of the all-time low counts as "rock-bottom era"
  const lowMonths = entries.filter((e) => e[1] <= THRESH).map((e) => e[0]);
  // collapse contiguous (month-adjacent) runs
  const windows = [];
  let run = null;
  const monthNum = (m) => { const [y, mm] = m.split('-').map(Number); return y * 12 + (mm - 1); };
  for (const m of lowMonths) {
    if (run && monthNum(m) - monthNum(run.end) <= 1) {
      run.end = m;
      run.min = Math.min(run.min, byMonth[m]);
    } else {
      if (run) windows.push(run);
      run = { start: m, end: m, min: byMonth[m] };
    }
  }
  if (run) windows.push(run);
  // only keep windows of >=3 months (ignore one-off blips), tag with min rate
  return windows
    .filter((w) => monthNum(w.end) - monthNum(w.start) >= 2)
    .map((w) => ({ start: w.start, end: w.end, minRate: +w.min.toFixed(2) }));
}

function inTrough(month, troughs) {
  return troughs.some((t) => month >= t.start && month <= t.end);
}

// ---- 2. HUD active multifamily insured mortgages ----
function loadHud() {
  let buf;
  try {
    buf = download(SRC.hudActiveXlsx);
    log('HUD: fetched', SRC.hudActiveXlsx, `(${(buf.length / 1e6).toFixed(1)}MB)`);
  } catch (e) {
    log('HUD fetch FAILED:', e.message);
    return [];
  }
  // cache the raw xlsx for provenance / re-runs
  try { fs.mkdirSync(RAW, { recursive: true }); fs.writeFileSync(path.join(RAW, 'hud-mf-active.xlsx'), buf); } catch (_) {}

  // xlsx may emit "Bad uncompressed size" warnings on HUD's files; data still parses. Silence them.
  const _warn = console.error; console.error = () => {};
  const wb = XLSX.read(buf, { type: 'buffer' });
  console.error = _warn;
  const ws = wb.Sheets[wb.SheetNames[0]];
  const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null });

  // header is the first row whose first cell is "HUD PROJECT NUMBER"
  let h = rows.findIndex((r) => r && String(r[0]).toUpperCase().includes('HUD PROJECT NUMBER'));
  if (h < 0) h = 1;
  const head = rows[h].map((c) => String(c == null ? '' : c).trim().toUpperCase());
  const col = (name) => head.indexOf(name);
  const cName = col('PROPERTY NAME');
  const cCity = col('PROPERTY CITY');
  const cState = col('PROPERTY STATE');
  const cZip = col('PROPERTY ZIP');
  const cUnits = col('UNITS');
  const cInit = col('INITIAL ENDORSEMENT DATE');
  const cAmt = col('ORIGINAL MORTGAGE AMOUNT');
  const cRate = col('INTEREST RATE');
  const cHolder = col('HOLDER NAME');

  const out = [];
  for (let i = h + 1; i < rows.length; i++) {
    const r = rows[i];
    if (!r || r.length === 0) continue;
    const rate = Number(r[cRate]);
    if (!isFinite(rate) || rate <= 0) continue;
    const name = r[cName] != null ? String(r[cName]).trim() : '';
    if (!name) continue;
    out.push({
      property: name,
      city: r[cCity] != null ? String(r[cCity]).trim() : '',
      state: r[cState] != null ? String(r[cState]).trim() : '',
      zip: r[cZip] != null ? String(r[cZip]).trim() : '',
      units: Number(r[cUnits]) || null,
      originalAmount: Number(r[cAmt]) || null,
      rate: +rate.toFixed(3),
      originationDate: excelSerialToISO(Number(r[cInit])),
      holder: cHolder >= 0 && r[cHolder] != null ? String(r[cHolder]).trim() : '',
    });
  }
  log(`HUD: parsed ${out.length} active insured multifamily mortgages with a real interest rate`);
  return out;
}

function main() {
  log('start — fetching REAL public HUD + PMMS data ($0, no paid APIs)');
  const { byMonth, troughs, rows: pmmsRows } = loadPmms();
  const hud = loadHud();

  // filter to original rate <= 3.5%
  const kept = hud.filter((l) => l.rate <= RATE_CAP);
  log(`filter: ${kept.length} of ${hud.length} loans have an original rate <= ${RATE_CAP}%`);

  let redCount = 0, belowOnly = 0, eraOnly = 0, both = 0;
  const rowsOut = kept.map((l) => {
    const month = l.originationDate ? l.originationDate.slice(0, 7) : null;
    const bench = month && byMonth[month] != null ? byMonth[month] : null;
    const belowMarket = bench != null && l.rate <= bench - BELOW_MARKET_GAP;
    const historicLowEra = month != null && inTrough(month, troughs);
    const redReasons = [];
    if (belowMarket) {
      redReasons.push(
        `Rate ${l.rate}% is ${(bench - l.rate).toFixed(2)} pts below the ${month} market avg of ${bench}%`
      );
    }
    if (historicLowEra) {
      const t = troughs.find((x) => month >= x.start && month <= x.end);
      redReasons.push(
        `Originated in a historic rock-bottom-rate window (${t.start}..${t.end}, market floor ~${t.minRate}%)`
      );
    }
    const red = belowMarket || historicLowEra;
    if (red) redCount++;
    if (belowMarket && historicLowEra) both++;
    else if (belowMarket) belowOnly++;
    else if (historicLowEra) eraOnly++;
    return {
      property: l.property,
      address: '', // HUD MF mortgage file carries property name + city/state/zip, not a street line
      city: l.city,
      state: l.state,
      zip: l.zip,
      units: l.units,
      originalAmount: l.originalAmount,
      rate: l.rate,
      originationDate: l.originationDate,
      pmmsBenchmark: bench,
      belowMarket,
      historicLowEra,
      red,
      redReasons,
      holder: l.holder,
    };
  });

  // sort: red first, then lowest rate, then largest loan
  rowsOut.sort((a, b) =>
    (b.red - a.red) || (a.rate - b.rate) || ((b.originalAmount || 0) - (a.originalAmount || 0))
  );

  const payload = {
    generatedAt: new Date().toISOString(),
    scope:
      'HUD/FHA MULTIFAMILY (commercial) insured mortgages — active. FHA single-family per-loan ' +
      'rate+address data is NOT public (borrower privacy), so single-family loans are out of scope.',
    filter: `original interest rate <= ${RATE_CAP}%`,
    redRule:
      'RED = belowMarket OR historicLowEra. belowMarket = loan rate >= ' +
      `${BELOW_MARKET_GAP} pct-pt below the PMMS 30-yr avg for its origination month; ` +
      'historicLowEra = origination month falls in a PMMS rock-bottom-rate trough (derived from the data).',
    sources: [
      { name: 'HUD/FHA Multifamily Insured Mortgages (Active)', url: SRC.hudActiveXlsx, page: SRC.hudPage },
      { name: 'Freddie Mac PMMS 30-yr fixed weekly history (canonical)', url: SRC.pmmsFreddie },
      { name: 'PMMS 30-yr series machine-readable feed (FRED MORTGAGE30US)', url: SRC.pmmsCsv },
    ],
    benchmarkMonths: Object.keys(byMonth).length,
    historicLowWindows: troughs,
    totalParsed: hud.length,
    count: rowsOut.length,
    redCount,
    redSplit: { belowMarketOnly: belowOnly, historicLowEraOnly: eraOnly, both },
    rows: rowsOut,
  };

  fs.mkdirSync(path.dirname(OUT), { recursive: true });
  fs.writeFileSync(OUT, JSON.stringify(payload, null, 2));
  log(`wrote ${OUT}`);
  log(
    `RESULT: ${rowsOut.length} loans <= ${RATE_CAP}% | ${redCount} RED ` +
    `(belowMarket-only ${belowOnly}, historicLowEra-only ${eraOnly}, both ${both}) | ` +
    `PMMS rows ${pmmsRows}`
  );
}

try { main(); } catch (e) { console.error('[fha-loans] FATAL', e); process.exit(1); }