← back to Nationalrealestate
src/ingest/derive_metrics.ts
86 lines
/**
* Derived metrics -> metric_series source 'derived'.
* For every county + metro:
* affordability = latest zhvi / ACS median_hh_income (annual ACS joins to any zhvi month)
* rent_yield = (zori * 12) / zhvi at the latest common zhvi+zori period
* zhvi_yoy = zhvi latest vs 12 months prior (fraction)
* period stored = the zhvi period used. Idempotent upserts via the shared harness.
*/
import { pool, query } from '../../db/pool.ts';
import { openRun, closeRun } from './run.ts';
import { upsertMetricRows, type MetricRow } from './crosswalk.ts';
const SOURCE = 'derived';
interface SeriesRow { region_id: number; metric: string; period: string; value: string }
async function main() {
const runId = await openRun(SOURCE);
try {
const r = await query<SeriesRow>(
`SELECT ms.region_id, ms.metric, to_char(ms.period, 'YYYY-MM-DD') AS period, ms.value::text AS value
FROM metric_series ms
JOIN region rg ON rg.id = ms.region_id AND rg.region_type IN ('county','metro')
WHERE (ms.source = 'zillow' AND ms.metric IN ('zhvi','zori'))
OR (ms.source = 'acs' AND ms.metric = 'median_hh_income')`,
);
type Per = { zhvi: Map<string, number>; zori: Map<string, number>; income?: number };
const byRegion = new Map<number, Per>();
for (const row of r.rows) {
let p = byRegion.get(row.region_id);
if (!p) { p = { zhvi: new Map(), zori: new Map() }; byRegion.set(row.region_id, p); }
const v = Number(row.value);
if (row.metric === 'zhvi') p.zhvi.set(row.period, v);
else if (row.metric === 'zori') p.zori.set(row.period, v);
else p.income = v;
}
const rows: MetricRow[] = [];
const monthsBack = (period: string, n: number) => {
const [y, m] = period.split('-').map(Number);
const total = y * 12 + (m - 1) - n;
return `${Math.floor(total / 12)}-${String((total % 12) + 1).padStart(2, '0')}-01`;
};
for (const [regionId, p] of byRegion) {
if (p.zhvi.size === 0) continue;
const zhviPeriods = [...p.zhvi.keys()].sort();
const latest = zhviPeriods[zhviPeriods.length - 1];
const zhvi = p.zhvi.get(latest)!;
if (!(zhvi > 0)) continue;
if (p.income && p.income > 0)
rows.push({ regionId, source: SOURCE, metric: 'affordability', period: latest, value: zhvi / p.income });
// rent_yield at the latest period where BOTH zhvi and zori exist
const common = [...p.zori.keys()].filter(k => p.zhvi.has(k)).sort();
if (common.length) {
const cp = common[common.length - 1];
const z = p.zhvi.get(cp)!, rent = p.zori.get(cp)!;
if (z > 0 && rent > 0)
rows.push({ regionId, source: SOURCE, metric: 'rent_yield', period: cp, value: (rent * 12) / z });
}
const prior = p.zhvi.get(monthsBack(latest, 12));
if (prior && prior > 0)
rows.push({ regionId, source: SOURCE, metric: 'zhvi_yoy', period: latest, value: zhvi / prior - 1 });
}
if (rows.length === 0) throw new Error('derive produced 0 rows — are zillow + acs ingests loaded?');
const upserted = await upsertMetricRows(rows, runId);
const counts: Record<string, number> = {};
for (const row of rows) counts[row.metric] = (counts[row.metric] || 0) + 1;
await closeRun(runId, 'ok', { upserted, notes: JSON.stringify(counts) });
console.log(`[derive] ok: ${upserted} rows`, counts);
} catch (e) {
await closeRun(runId, 'failed', { notes: String(e) });
console.error('[derive] FAILED:', e);
process.exitCode = 1;
} finally {
await pool.end();
}
}
main();