← back to Commercialrealestate

scripts/assumable-heuristic.js

209 lines

// assumable-heuristic.js — estimate P(a for-sale property carries an assumable FHA/VA loan) for every
// condo + SFR in cre, and UPSERT the result into cre.assumable_estimate.
//
// WHY: FHA & VA loans are assumable — a qualified buyer can take over the seller's existing low-rate
// note. A listing likely carrying a sub-4% assumable FHA/VA loan is a notable signal for buyers.
// The AUTHORITATIVE source is the recorded deed of trust in TITLE RECORDS. We do not have
// title access yet, so this is a transparent HEURISTIC screen that plugs title data in the moment the
// adapter (scripts/sources/title-records.js) is credentialed.
//
// HONEST LABELING (HARD): every estimate is a screening signal, confidence 'heuristic — verify in title
// records'. The heuristic CAN misfire (a 2021 sale may have been cash/conventional, not FHA/VA) — it is
// call-prioritization, never an authoritative assumption finding. When the title adapter returns a real
// recorded loan, basis flips to 'title' and confidence to 'title-verified' for that property.
//
// HEURISTIC INPUTS (all from what we already have, $0 local):
//   1. PROPERTY TYPE  — condos + SFR are FHA/VA-eligible; commercial is not. (We only score condo+sfr.)
//   2. PRICE BAND     — FHA/VA borrow within program limits. LA County 2024 FHA ceiling ~$1,149,825; VA
//                       has no cap but jumbo-VA is rare. Above the FHA ceiling, FHA is impossible and a
//                       large VA loan is unlikely -> likelihood drops.
//   3. PURCHASE ERA   — when the property's address matches a closed_sale, the SALE YEAR dates the loan.
//                       2020-2022 purchases sit on sub-4% rates (assumption is most valuable then).
//                       The rate is read from assumable_rate_proxy (APPROXIMATE historical averages).
//
// Usage: node scripts/assumable-heuristic.js            (recompute all; applies schema first)
//        node scripts/assumable-heuristic.js --limit 50 (smoke test)
'use strict';
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
const titleRecords = require('./sources/title-records');

const ROOT = path.join(__dirname, '..');
const pool = new Pool({
  host: process.env.CRE_PGHOST || '/tmp', port: +(process.env.CRE_PGPORT || 5432),
  database: process.env.CRE_PGDATABASE || 'cre', user: process.env.CRE_PGUSER || process.env.USER || 'stevestudio2'
});

const FHA_CEILING_LA_2024 = 1149825;   // LA County 2024 FHA one-unit loan limit (the price-band cutoff)

// APPROXIMATE annual-average FHA/VA 30yr fixed rates by recording year. Ballpark historical averages
// (Freddie Mac PMMS-grade), used only to infer the rate a loan recorded that year likely carries.
// Labelled approximate everywhere it surfaces. Pre-2016 collapses into a single ~4.0% bucket.
const RATE_PROXY = [
  [2016, 3.65, 'approx avg 30yr fixed 2016'],
  [2017, 3.99, 'approx avg 30yr fixed 2017'],
  [2018, 4.54, 'approx avg 30yr fixed 2018'],
  [2019, 3.94, 'approx avg 30yr fixed 2019'],
  [2020, 3.11, 'approx avg 30yr fixed 2020 — sub-4 era'],
  [2021, 2.96, 'approx avg 30yr fixed 2021 — record-low era'],
  [2022, 5.34, 'approx avg 30yr fixed 2022 — rates rising'],
  [2023, 6.81, 'approx avg 30yr fixed 2023'],
  [2024, 6.72, 'approx avg 30yr fixed 2024'],
  [2025, 6.80, 'approx avg 30yr fixed 2025 (provisional)'],
  [2026, 6.70, 'approx avg 30yr fixed 2026 (provisional)']
];
// Normalize a street address to a join key: lowercase, strip unit/suite/#, collapse whitespace.
const normAddr = (a) => String(a || '').toLowerCase()
  .replace(/\b(unit|apt|apartment|ste|suite|#)\s*[\w-]+/g, '')
  .replace(/[.,]/g, ' ').replace(/\s+/g, ' ').trim();

async function applySchema() {
  await pool.query(fs.readFileSync(path.join(__dirname, 'db', 'assumable-schema.sql'), 'utf8'));
  // (Re)seed the rate proxy idempotently.
  for (const [yr, rate, note] of RATE_PROXY) {
    await pool.query(`INSERT INTO assumable_rate_proxy(rate_year, est_rate, note) VALUES($1,$2,$3)
      ON CONFLICT(rate_year) DO UPDATE SET est_rate=EXCLUDED.est_rate, note=EXCLUDED.note`, [yr, rate, note]);
  }
}

// Build an address -> most-recent sale-year map from closed_sale (the era-sharpening signal).
async function saleYearByAddress() {
  const r = await pool.query(
    `SELECT address, zip, date_part('year', sold_date)::int yr FROM closed_sale
     WHERE sold_date IS NOT NULL ORDER BY sold_date ASC`);
  const m = new Map();   // key = normAddr|zip  (last write wins -> most recent sale, since ordered ASC)
  for (const row of r.rows) m.set(normAddr(row.address) + '|' + (row.zip || ''), row.yr);
  return m;
}

const rateForYear = (yr) => {
  if (yr == null) return null;
  const hit = RATE_PROXY.find(([y]) => y === yr);
  if (hit) return hit[1];
  return yr < 2016 ? 4.00 : RATE_PROXY[RATE_PROXY.length - 1][1];
};

// The core heuristic. Given a property + an optional matched sale year, produce the estimate object.
function scoreProperty(p, saleYear) {
  const signals = [];
  let score = 0;   // 0..3 informal; mapped to low/medium/high at the end

  // (1) type — condo + SFR are FHA/VA-eligible stock (we only feed those in, so this always contributes).
  signals.push({ factor: 'property_type', value: p.src, note: `${p.src} is FHA/VA-eligible stock` });
  score += 1;

  // (2) price band vs FHA ceiling.
  const price = Number(p.price) || 0;
  if (price > 0 && price <= FHA_CEILING_LA_2024) {
    signals.push({ factor: 'price_band', value: price, note: `<= LA 2024 FHA limit $${FHA_CEILING_LA_2024.toLocaleString()} — FHA/VA-financeable` });
    score += 1;
  } else if (price > FHA_CEILING_LA_2024) {
    signals.push({ factor: 'price_band', value: price, note: `> LA 2024 FHA limit — FHA impossible, jumbo-VA rare` });
    score -= 1;
  } else {
    signals.push({ factor: 'price_band', value: null, note: 'price unknown' });
  }

  // (3) purchase era — only when an address matched a closed_sale. 2020-2022 -> sub-4% loan likely.
  let estRate = null, estSaleYear = null;
  if (saleYear != null) {
    estSaleYear = saleYear;
    estRate = rateForYear(saleYear);
    if (saleYear >= 2020 && saleYear <= 2022) {
      signals.push({ factor: 'purchase_era', value: saleYear, note: `bought ${saleYear} (sub-4% era) — assumable rate ~${estRate}% (approx)` });
      score += 1;
    } else {
      signals.push({ factor: 'purchase_era', value: saleYear, note: `bought ${saleYear} — est rate ~${estRate}% (approx); assumption value lower` });
    }
  } else {
    signals.push({ factor: 'purchase_era', value: null, note: 'no matched recent sale — era unknown' });
  }

  // Map informal score -> likelihood. High requires BOTH a financeable price AND a sub-4% era match.
  const subFourEra = saleYear != null && saleYear >= 2020 && saleYear <= 2022;
  const financeable = price > 0 && price <= FHA_CEILING_LA_2024;
  let likelihood;
  if (financeable && subFourEra) likelihood = 'high';
  else if (financeable && (saleYear == null || score >= 2)) likelihood = 'medium';
  else likelihood = 'low';

  const basisBits = [`${p.src} ${price ? '$' + price.toLocaleString() : 'price?'}`,
    financeable ? '≤FHA limit' : '>FHA limit',
    saleYear != null ? `last sale ${saleYear} → ~${estRate}% (approx)` : 'sale era unknown'];

  return {
    assumable_likelihood: likelihood,
    est_assumable_rate: estRate,
    est_sale_year: estSaleYear,
    basis: basisBits.join('; '),
    confidence: 'heuristic — verify in title records',
    signals: { factors: signals, source: 'heuristic' }
  };
}

(async () => {
  const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? +process.argv[i + 1] : 0; })();
  await applySchema();

  // The title adapter plugs in here. When credentialed it returns real recorded loans; until then it is
  // {available:false} and we fall through to the heuristic for every property.
  const titleStatus = await titleRecords.status();
  if (titleStatus.available) console.log('title-records adapter ONLINE — verified loans will override the heuristic.');
  else console.log(`title-records adapter offline (${titleStatus.needs}) — heuristic only. Estimates are screening signals.`);

  const saleYears = await saleYearByAddress();
  const props = (await pool.query(
    `SELECT id, address, zip, price, 'condo' src FROM condo
     UNION ALL SELECT id, address, zip, price, 'sfr' src FROM sfr
     ${LIMIT ? 'LIMIT ' + LIMIT : ''}`)).rows;

  let high = 0, med = 0, low = 0, titleVerified = 0;
  for (const p of props) {
    let est;
    // 1) Title records FIRST — authoritative when available.
    const recorded = await titleRecords.getRecordedLoans({ address: p.address, city: p.city, zip: p.zip });
    if (recorded && recorded.available && Array.isArray(recorded.loans)) {
      const fhaVa = recorded.loans.find(l => /fha|va/i.test(l.type || ''));
      if (fhaVa) {
        titleVerified++;
        est = {
          assumable_likelihood: 'high',
          est_assumable_rate: fhaVa.rate ?? rateForYear(new Date(fhaVa.recordedDate).getFullYear()),
          est_sale_year: fhaVa.recordedDate ? new Date(fhaVa.recordedDate).getFullYear() : null,
          basis: `recorded ${fhaVa.type} deed of trust (${fhaVa.lender || 'lender?'}, ${fhaVa.recordedDate || 'date?'})`,
          confidence: 'title-verified',
          signals: { factors: [{ factor: 'title_record', value: fhaVa }], source: 'title' }
        };
      }
    }
    // 2) Fall back to the heuristic.
    if (!est) {
      const yr = saleYears.get(normAddr(p.address) + '|' + (p.zip || ''));
      est = scoreProperty(p, yr);
    }

    if (est.assumable_likelihood === 'high') high++;
    else if (est.assumable_likelihood === 'medium') med++;
    else low++;

    await pool.query(
      `INSERT INTO assumable_estimate(property_id, src, assumable_likelihood, est_assumable_rate,
         est_sale_year, basis, confidence, signals, computed_at)
       VALUES($1,$2,$3,$4,$5,$6,$7,$8,now())
       ON CONFLICT(property_id) DO UPDATE SET
         src=EXCLUDED.src, assumable_likelihood=EXCLUDED.assumable_likelihood,
         est_assumable_rate=EXCLUDED.est_assumable_rate, est_sale_year=EXCLUDED.est_sale_year,
         basis=EXCLUDED.basis, confidence=EXCLUDED.confidence, signals=EXCLUDED.signals,
         computed_at=now()`,
      [p.id, p.src, est.assumable_likelihood, est.est_assumable_rate, est.est_sale_year,
       est.basis, est.confidence, JSON.stringify(est.signals)]);
  }

  console.log(`\nassumable estimate computed for ${props.length} properties`);
  console.log(`  high ${high} | medium ${med} | low ${low} | title-verified ${titleVerified}`);
  console.log('cost: $0 (local Postgres + local heuristic; no paid API).');
  console.log('LABEL: every estimate is "heuristic — verify in title records" until the title adapter is credentialed.');
  await pool.end();
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });