← 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
M public/markets.htmlM score-config.jsonM src/score/opportunity.tsM src/server/index.ts
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 →