[object Object]

← back to Nationalrealestate

Contrarian fixes: thin-market floor (pop<50k or <10 monthly sales excluded from rank, 2200 flagged; real markets now top-10), small-markets toggle default-hidden, data-through freshness badge, server under pm2

731695f087a2ba4766e9869597df458e835dc861 · 2026-07-21 13:33:15 -0700 · Steve Abrams

Files touched

Diff

commit 731695f087a2ba4766e9869597df458e835dc861
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 21 13:33:15 2026 -0700

    Contrarian fixes: thin-market floor (pop<50k or <10 monthly sales excluded from rank, 2200 flagged; real markets now top-10), small-markets toggle default-hidden, data-through freshness badge, server under pm2
---
 public/markets.html      | 23 +++++++++++++++++++++--
 score-config.json        |  4 ++++
 src/score/opportunity.ts | 25 ++++++++++++++++++++++---
 src/server/index.ts      |  6 +++++-
 4 files changed, 52 insertions(+), 6 deletions(-)

diff --git a/public/markets.html b/public/markets.html
index 60be745..4dd923e 100644
--- a/public/markets.html
+++ b/public/markets.html
@@ -37,7 +37,11 @@
     <a href="/watchlist.html">Watchlist</a>
     <a href="/admin.html">Admin</a>
   </nav>
-  <span id="typeSeg">
+  <span id="freshness" style="margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap;"></span>
+  <label id="thinToggle" style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;">
+    <input type="checkbox" id="showThin"> small markets
+  </label>
+  <span id="typeSeg" style="margin-left:0">
     <button data-type="county">Counties</button>
     <button data-type="metro">Metros</button>
   </span>
@@ -82,12 +86,23 @@ function sub100(v){ return v == null ? null : Math.round(v * 100); }
 function pctv(v){ return v == null ? null : +(v * 100).toFixed(2); }
 
 let grid = null;
+let allRows = [];
+// thin markets (<50k pop or <10 monthly sales) carry no rank and hide by default —
+// a 795-person county topping the national list is noise, not signal
+const showThinEl = document.getElementById('showThin');
+showThinEl.checked = localStorage.getItem('usreMkt.showThin') === '1';
+showThinEl.addEventListener('change', () => {
+  localStorage.setItem('usreMkt.showThin', showThinEl.checked ? '1' : '0');
+  if (grid) grid.setData(visibleRows());
+});
+function visibleRows(){ return showThinEl.checked ? allRows : allRows.filter(r => !r.thin_market); }
 async function load(type){
   TYPE = type;
   localStorage.setItem('usreMkt.type', type);
   document.querySelectorAll('#typeSeg button').forEach(b => b.classList.toggle('active', b.dataset.type === type));
   const d = await fetch('/api/markets?type=' + type).then(r => r.json());
-  const rows = d.rows || [];
+  allRows = d.rows || [];
+  const rows = visibleRows();
   const loading = document.getElementById('loading');
   if (loading) loading.remove();
   if (grid){ grid.setData(rows); return; }
@@ -116,6 +131,10 @@ async function load(type){
     ],
   });
 }
+fetch('/api/health').then(r => r.json()).then(d => {
+  if (d.data_through) document.getElementById('freshness').textContent =
+    'data through ' + new Date(d.data_through + 'T12:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short' });
+});
 document.getElementById('typeSeg').addEventListener('click', e => {
   const b = e.target.closest('button[data-type]');
   if (b && b.dataset.type !== TYPE) load(b.dataset.type);
diff --git a/score-config.json b/score-config.json
index 1874792..90756ec 100644
--- a/score-config.json
+++ b/score-config.json
@@ -11,5 +11,9 @@
     "score_scale": 100,
     "clamp": [0, 100],
     "thin_data_threshold": 0.6
+  },
+  "thin_market": {
+    "min_population": 50000,
+    "min_monthly_homes_sold": 10
   }
 }
diff --git a/src/score/opportunity.ts b/src/score/opportunity.ts
index 264e823..63adbb3 100644
--- a/src/score/opportunity.ts
+++ b/src/score/opportunity.ts
@@ -11,7 +11,9 @@
  *   velocity        = mean(inverse DOM percentile, sale_to_list percentile)
  * Null component -> dropped + weights renormalized; data_completeness = fraction of 5 present.
  * opportunity_score = renormalized weighted sum x 100, clamped 0-100.
- * Rank = dense by score desc within region_type.
+ * Rank = dense by score desc within region_type — THIN MARKETS EXCLUDED from rank
+ * (population < 50k or < 10 monthly sales: a 795-person county with one 637%-YoY sale
+ * must never outrank real markets; they keep a score + components.thin_market=true, rank NULL).
  */
 import { readFileSync } from 'node:fs';
 import { join, dirname } from 'node:path';
@@ -46,7 +48,7 @@ async function main() {
          FROM metric_series ms
          JOIN region rg ON rg.id = ms.region_id AND rg.region_type IN ('county','metro')
         WHERE ms.metric IN ('zhvi_yoy','median_sale_price_yoy','affordability','rent_yield',
-                            'months_of_supply','dom','sale_to_list')
+                            'months_of_supply','dom','sale_to_list','homes_sold')
         ORDER BY ms.region_id, ms.metric, ms.period DESC
      )
      SELECT region_id, region_type, metric, value::text AS value FROM latest
@@ -70,6 +72,12 @@ async function main() {
   const scoreDateR = await query<{ d: string }>(`SELECT CURRENT_DATE::text AS d`);
   const scoreDate = scoreDateR.rows[0].d;
 
+  const popR = await query<{ id: number; population: number | null }>(
+    `SELECT id, population FROM region WHERE region_type IN ('county','metro')`,
+  );
+  const popById = new Map(popR.rows.map(r => [r.id, r.population]));
+  const TM = CONFIG.thin_market || { min_population: 50000, min_monthly_homes_sold: 10 };
+
   let totalUpserts = 0;
   for (const regionType of ['county', 'metro']) {
     const regions = [...byRegion.entries()]
@@ -129,6 +137,13 @@ async function main() {
       const score = Math.min(100, Math.max(0,
         present.reduce((a, c) => a + weightsUsed[c] * (sub[c] as number), 0) * 100));
 
+      const population = popById.get(id) ?? null;
+      const homesSold = (id => { const o = byRegion.get(id); const v = o?.m['homes_sold']; return Number.isFinite(v) ? (v as number) : null; })(id);
+      const thinMarket =
+        (population != null && population < TM.min_population) ||
+        (homesSold != null && homesSold < TM.min_monthly_homes_sold) ||
+        (population == null && homesSold == null);
+
       results.push({
         regionId: id,
         score: Math.round(score * 100) / 100,
@@ -136,6 +151,9 @@ async function main() {
           sub_scores: sub,
           weights_used: weightsUsed,
           data_completeness: round6(present.length / COMPONENTS.length),
+          thin_market: thinMarket,
+          population,
+          homes_sold_latest: homesSold,
           raw: {
             zhvi_yoy: r.zhvi_yoy, median_sale_price_yoy: r.msp_yoy,
             affordability: r.affordability_raw, rent_yield: r.rent_yield_raw,
@@ -147,10 +165,11 @@ async function main() {
       });
     }
 
-    // dense rank by score desc within region_type
+    // dense rank by score desc within region_type — thin markets carry NO rank
     results.sort((a, b) => b.score - a.score || a.regionId - b.regionId);
     let rank = 0, prevScore: number | null = null;
     for (const res of results) {
+      if (res.components.thin_market) { (res as any).rank = null; continue; }
       if (res.score !== prevScore) { rank++; prevScore = res.score; }
       (res as any).rank = rank;
     }
diff --git a/src/server/index.ts b/src/server/index.ts
index 14e64d8..d48c1af 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -38,7 +38,10 @@ app.get('/api/health', async (_req, res) => {
     );
     const regions: Record<string, number> = {};
     for (const row of r.rows) regions[row.region_type] = Number(row.n);
-    res.json({ ok: true, regions });
+    const fresh = await query<{ d: string | null }>(
+      `SELECT to_char(MAX(period),'YYYY-MM-DD') AS d FROM metric_series WHERE metric='zhvi'`,
+    );
+    res.json({ ok: true, regions, data_through: fresh.rows[0]?.d ?? null });
   } catch (e: any) {
     res.status(500).json({ ok: false, error: String(e.message || e) });
   }
@@ -160,6 +163,7 @@ app.get('/api/markets', async (req, res) => {
         rent_yield_score: sub.rent_yield ?? null, inventory_trend: sub.inventory_trend ?? null,
         velocity: sub.velocity ?? null,
         data_completeness: s?.components?.data_completeness ?? null,
+        thin_market: s?.components?.thin_market ?? false,
         zhvi: m.zhvi ?? null, zhvi_yoy: m.zhvi_yoy ?? null,
         median_sale_price: m.median_sale_price ?? null, median_sale_price_yoy: m.median_sale_price_yoy ?? null,
         inventory: m.inventory ?? null, dom: m.dom ?? null,

← a7a865e M5 complete: watchlist/alerts/admin routes mounted and verif  ·  back to Nationalrealestate  ·  CCK#2 verified fixes: HTML-escape admin provenance fields, g d8824b4 →