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