← back to Commercialrealestate

scripts/classify-warrantability.js

165 lines

// classify-warrantability.js — warrantability classifier for LA condos (FHA/VA-approval-based PROXY).
//
// HONEST LABELING (hard rule): this returns a PROXY, NOT a lender-verified Fannie/Freddie warrantability
// result. Primary signal = match against the public HUD FHA-approved condo list (data/fha-approved-condos.json).
// Secondary = heuristic text flags (condotel / short-term-rental, "cash only" / "not warrantable" /
// "investor", high commercial ratio, new-construction <% sold). Every signal is labeled with its basis.
//
// API:
//   const { classify, loadFhaList } = require('./classify-warrantability');
//   const fha = loadFhaList();
//   classify({ project_name, address, city, zip, listing_text, commercial_ratio, pct_sold }, fha)
//     -> { status, label, source, signals: [{ kind, level, basis, note }] }
//
// status ∈ fha_approved | fha_expired | not_listed | heuristic_flag
//   fha_approved   — on the FHA list, currently approved (financing-eligible proxy)
//   fha_expired    — on the FHA list but approval lapsed (was eligible; re-cert needed)
//   heuristic_flag — NOT on the list AND a hard non-warrantable text signal fired (flag for review)
//   not_listed     — not on the FHA list, no hard flags (unknown; flag for lender verification)

'use strict';
const fs = require('fs');
const path = require('path');

const PROXY_LABEL = 'FHA/VA-approval-based proxy, NOT lender-verified Fannie/Freddie warrantability.';

function loadFhaList(file) {
  file = file || path.join(__dirname, '..', 'data', 'fha-approved-condos.json');
  const { condos } = JSON.parse(fs.readFileSync(file, 'utf8'));
  // Index by normalized project name and by zip for fast lookup.
  const byName = new Map();
  const byZip = new Map();
  for (const c of condos) {
    const nk = norm(c.project_name);
    if (nk) { if (!byName.has(nk)) byName.set(nk, []); byName.get(nk).push(c); }
    if (c.zip) { if (!byZip.has(c.zip)) byZip.set(c.zip, []); byZip.get(c.zip).push(c); }
  }
  return { condos, byName, byZip };
}

const norm = s => (s || '').toLowerCase().replace(/\b(condominiums?|condos?|owners?|hoa|association|apartments?|the)\b/g, '')
  .replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();

// FHA-list match: try exact normalized project-name match (optionally zip-scoped), then a
// street-number + zip match against the FHA record's own address. Returns the best FHA record or null.
function fhaMatch(condo, fha) {
  const want = norm(condo.project_name);
  // 1) project-name match, prefer same-zip + approved
  if (want && want.length > 2 && fha.byName.has(want)) {
    const cands = fha.byName.get(want)
      .filter(c => !condo.zip || !c.zip || c.zip === condo.zip);
    const pick = pickBest(cands);
    if (pick) return { rec: pick, how: 'project-name match' };
  }
  // 2) street-number + zip match against FHA address (catches address-only listings)
  const num = (condo.address || '').match(/^\s*(\d+)/);
  if (num && condo.zip && fha.byZip.has(condo.zip)) {
    const cands = fha.byZip.get(condo.zip).filter(c => (c.address || '').trim().startsWith(num[1] + ' '));
    const pick = pickBest(cands);
    if (pick) return { rec: pick, how: 'street-number + zip match' };
  }
  return null;
}

function pickBest(cands) {
  if (!cands || !cands.length) return null;
  // Prefer currently-approved over expired.
  return cands.sort((a, b) =>
    (a.warrant_signal === 'fha_approved' ? 0 : 1) - (b.warrant_signal === 'fha_approved' ? 0 : 1))[0];
}

// Heuristic non-warrantable text flags. Each returns a signal when its pattern hits the listing text.
const HEURISTICS = [
  { kind: 'condotel', level: 'hard',
    re: /\b(condo[\s-]?tel|hotel[\s-]?condo|resort condominium|short[\s-]?term rental|airbnb|vrbo|nightly rental|transient occupancy)\b/i,
    note: 'condotel / short-term-rental operation — typically non-warrantable' },
  { kind: 'cash_only', level: 'hard',
    re: /\b(cash[\s-]?only|cash buyers? only|no financing|not warrantable|non[\s-]?warrantable|unwarrantable|will not qualify for financing)\b/i,
    note: 'listing states cash-only / not-warrantable' },
  { kind: 'litigation', level: 'hard',
    re: /\b(pending litigation|hoa litigation|active lawsuit|construction defect (litigation|suit))\b/i,
    note: 'HOA / construction-defect litigation — common warrantability disqualifier' },
  { kind: 'investor_heavy', level: 'soft',
    re: /\b(investor[\s-]?owned|high (investor|rental) concentration|majority (rented|tenant[\s-]?occupied)|low owner[\s-]?occupancy)\b/i,
    note: 'high investor/rental concentration noted — may fail owner-occupancy thresholds' },
  { kind: 'commercial_mixed', level: 'soft',
    re: /\b(mixed[\s-]?use|ground[\s-]?floor (retail|commercial)|commercial space below|live[\s-]?work)\b/i,
    note: 'mixed-use / commercial component — high commercial ratio can be non-warrantable' },
  { kind: 'new_construction', level: 'soft',
    re: /\b(new construction|pre[\s-]?construction|under construction|to be built|brand new development|phase \d+ now selling)\b/i,
    note: 'new-construction / early-phase — pre-sale % can fail warrantability until enough units close' }
];

function classify(condo, fha) {
  const signals = [];
  const text = (condo.listing_text || '') + ' ' + (condo.project_name || '');

  // Primary: FHA-list match.
  const m = fhaMatch(condo, fha);
  if (m) {
    const r = m.rec;
    signals.push({
      kind: 'fha_list', level: 'primary',
      basis: 'HUD FHA-approved condo list (' + m.how + ')',
      note: `${r.project_name} — HUD ${r.status}` +
            (r.expiration_date ? `, expires ${r.expiration_date}` : '') +
            (r.fha_concentration ? `, FHA concentration ${r.fha_concentration}` : '')
    });
  }

  // Secondary: heuristic flags (always evaluated, for transparency).
  for (const h of HEURISTICS) {
    if (h.re.test(text)) signals.push({ kind: h.kind, level: h.level, basis: 'listing-text heuristic', note: h.note });
  }
  // Numeric heuristics when fields are present.
  if (condo.commercial_ratio != null && condo.commercial_ratio > 0.35) {
    signals.push({ kind: 'commercial_ratio', level: 'hard', basis: 'computed commercial ratio',
      note: `commercial space ${Math.round(condo.commercial_ratio * 100)}% (>35% non-warrantable threshold)` });
  }
  if (condo.pct_sold != null && condo.pct_sold < 0.5) {
    signals.push({ kind: 'pct_sold', level: 'soft', basis: 'computed pre-sale %',
      note: `only ${Math.round(condo.pct_sold * 100)}% of units sold (<50% — typically not yet warrantable)` });
  }

  // Resolve status. FHA match dominates; else hard heuristic flag; else not_listed.
  let status, source;
  if (m && m.rec.warrant_signal === 'fha_approved') {
    status = 'fha_approved';
    source = `HUD FHA list (${m.rec.condo_id || '?'} ${m.rec.submission || ''}, Approved` +
             (m.rec.expiration_date ? `, exp ${m.rec.expiration_date}` : '') + ')';
  } else if (m && m.rec.warrant_signal === 'fha_expired') {
    status = 'fha_expired';
    source = `HUD FHA list (${m.rec.condo_id || '?'} ${m.rec.submission || ''}, Expired` +
             (m.rec.expiration_date ? ` ${m.rec.expiration_date}` : '') + ')';
  } else if (signals.some(s => s.level === 'hard')) {
    status = 'heuristic_flag';
    source = 'heuristic: ' + signals.filter(s => s.level === 'hard').map(s => s.kind).join(', ');
  } else {
    status = 'not_listed';
    source = 'not on FHA list; no hard flags — verify with lender';
  }

  return { status, label: PROXY_LABEL, source, signals };
}

module.exports = { classify, loadFhaList, fhaMatch, PROXY_LABEL };

// CLI: pipe a JSON condo, or run with a built-in sample set, to see classifications.
if (require.main === module) {
  const fha = loadFhaList();
  const samples = [
    { project_name: '1200 NORTH', address: '1200 N FLORES ST', city: 'WEST HOLLYWOOD', zip: '90069', listing_text: 'Gorgeous 2BR condo in prime WeHo.' },
    { project_name: 'Some Random Tower', address: '500 Nowhere Ave', city: 'Los Angeles', zip: '90017', listing_text: 'CASH ONLY — not warrantable, investor special, ground-floor retail below.' },
    { project_name: 'Marina Pointe', address: '4267 Marina City Dr', city: 'Marina Del Rey', zip: '90292', listing_text: 'Luxury high-rise, short-term rental / airbnb friendly building.' },
    { project_name: 'Unknown Condos', address: '123 Main St', city: 'Glendale', zip: '91205', listing_text: 'Bright move-in ready 1BR, low HOA.' },
    { project_name: '10919 BLIX CONDOMINIUMS', address: '10925 BLIX STREET', city: 'NORTH HOLLYWOOD', zip: '91602', listing_text: 'Updated unit.' }
  ];
  for (const s of samples) {
    const r = classify(s, fha);
    console.log(`\n${s.project_name} (${s.city} ${s.zip})`);
    console.log(`  STATUS: ${r.status}  [${r.source}]`);
    r.signals.forEach(sig => console.log(`    • [${sig.level}/${sig.kind}] ${sig.note}  (${sig.basis})`));
  }
  console.log('\n' + PROXY_LABEL);
}