← back to Commercialrealestate
public/deal-score.js
113 lines
// deal-score.js — transparent, weight-normalized 0-100 Deal Score for the LA County CRE screener.
// Works in Node (require) and the browser (global DealScore). SINGLE SOURCE OF TRUTH so the
// property cards, the map color-by-score ramp, and the deals dashboard all rank identically.
//
// Philosophy: synthesize the signals we ALREADY computed elsewhere into one "chase-this-first" number.
// - Every sub-score is a labeled 0-100 mapping of one real signal.
// - dealScore = Σ(sub × weight) / Σ(weight) over ONLY the components that are present.
// Missing signals drop out and the remaining weights re-normalize → never NaN, never a free ride.
// - Bonuses (assumable, FHA-warrantable) are upside nudges with small weight, applied the same way.
// - We are honest: proxy/low-confidence sub-scores are labeled as such in the breakdown so the UI
// can show WHY a deal scored what it did. This is a screen, not an appraisal.
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.DealScore = factory();
})(typeof self !== 'undefined' ? self : this, function () {
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const fin = v => (typeof v === 'number' && isFinite(v)) ? v : null;
// linear map of x in [a,b] -> [0,100], clamped; guards a===b (div-by-zero) -> 50.
function ramp(x, a, b) { x = fin(x); if (x == null) return null; if (a === b) return 50; return clamp(100 * (x - a) / (b - a), 0, 100); }
// Component weights (relative; the formula re-normalizes over whatever is present).
const W = { rentRoi: 30, cap: 28, dscr: 22, assumable: 12, warrant: 8 };
// ---- sub-score mappings (each returns 0-100 or null when the signal is absent) ----
// Pro-forma gross rent yield. LA gross yields run ~5-10%; >18% is almost always a bad unit count.
// Clamp those outliers to low-confidence: cap the points at 60 AND halve the weight so a fishy
// listing can't top the board on a number we don't trust.
function rentRoiSub(grossYieldPro) {
const y = fin(grossYieldPro); if (y == null) return null;
if (y > 18) return { core: true, score: clamp(ramp(y, 4, 12), 0, 60), weight: W.rentRoi * 0.5, conf: 'low', label: 'Pro-forma rent yield (outlier — verify units)' };
return { core: true, score: ramp(y, 4, 12), weight: W.rentRoi, conf: 'proxy', label: 'Pro-forma rent yield' };
}
// Cap rate (commercial). 4% -> 0, 9% -> 100. Broker-stated unless verified.
function capSub(cap_rate, verified) {
const c = fin(cap_rate); if (c == null) return null;
return { core: true, score: ramp(c, 4, 9), weight: W.cap, conf: verified === true ? 'verified' : 'broker', label: verified === true ? 'Cap rate (verified)' : 'Cap rate (broker-stated)' };
}
// DSCR-readiness. Prefer the real leverage-math DSCR. When there's no in-place NOI but we have a
// rent estimate, derive a PROXY DSCR = est NOI vs the standard-loan debt service the caller passes.
// 1.0 -> 0, 1.5 -> 100 (1.25 = the bankable line ~ 50pts).
function dscrSub(dscr, proxyDscr) {
const real = fin(dscr);
if (real != null) return { core: true, score: ramp(real, 1.0, 1.5), weight: W.dscr, conf: 'derived', label: 'DSCR (from in-place cap)' };
const px = fin(proxyDscr);
if (px != null) return { core: true, score: ramp(px, 1.0, 1.5), weight: W.dscr * 0.7, conf: 'proxy', label: 'DSCR-readiness (rent-estimate proxy)' };
return null;
}
// Assumable FHA/VA bonus (heuristic, not title-verified). high > medium.
function assumableSub(assum) {
if (!assum || !assum.lk) return null;
const score = assum.lk === 'high' ? 100 : assum.lk === 'medium' ? 65 : null;
if (score == null) return null;
return { score, weight: W.assumable, conf: 'heuristic', label: 'Assumable FHA/VA (heuristic)' };
}
// FHA-warrantability bonus (condo only). Binary signal -> full credit when approved.
function warrantSub(fhaApproved) {
if (fhaApproved !== true) return null;
return { score: 100, weight: W.warrant, conf: 'signal', label: 'FHA-warrantable condo' };
}
// ---- main: assemble present components, weight-normalize, never NaN ----
// input: { grossYieldPro, cap_rate, verified, dscr, proxyDscr, assum, fhaApproved }
function compute(input) {
input = input || {};
const parts = [
rentRoiSub(input.grossYieldPro),
capSub(input.cap_rate, input.verified),
dscrSub(input.dscr, input.proxyDscr),
assumableSub(input.assum),
warrantSub(input.fhaApproved),
].filter(p => p && fin(p.score) != null && fin(p.weight) != null && p.weight > 0);
if (!parts.length) return { score: null, parts: [], confidence: 'none' };
const wsum = parts.reduce((s, p) => s + p.weight, 0);
if (!(wsum > 0)) return { score: null, parts, confidence: 'none' };
const raw = parts.reduce((s, p) => s + p.score * p.weight, 0) / wsum;
const score = clamp(Math.round(raw), 0, 100);
// Confidence: how much of the FULL core weight budget is backed by present signals, and whether
// any core return signal (rent/cap/dscr) is present at all (bonuses alone = thin, untrustworthy).
const coreWeight = parts.filter(p => p.core).reduce((s, p) => s + p.weight, 0);
const coverage = coreWeight / (W.rentRoi + W.cap + W.dscr); // bonuses excluded from the denominator
const confidence = coreWeight <= 0 ? 'thin' : coverage >= 0.7 ? 'high' : coverage >= 0.35 ? 'med' : 'low';
return { score, parts, confidence, coverage: +coverage.toFixed(2) };
}
// Tier + color ramp for the map (sequential 0-100; distinct from the type + price palettes).
// 5 tiers so a legend key is readable. n/a -> grey.
const TIERS = [
{ min: 80, c: '#16a34a', label: '80–100 · chase now' },
{ min: 60, c: '#84cc16', label: '60–79 · strong' },
{ min: 40, c: '#eab308', label: '40–59 · watch' },
{ min: 20, c: '#f97316', label: '20–39 · weak' },
{ min: 0, c: '#dc2626', label: '0–19 · pass' },
];
const SCORE_NA = '#6b7280';
function scoreColor(score) {
const s = fin(score); if (s == null) return SCORE_NA;
for (const t of TIERS) if (s >= t.min) return t.c;
return SCORE_NA;
}
return { compute, scoreColor, TIERS, SCORE_NA, WEIGHTS: W, _ramp: ramp };
});