← 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);
}