← back to Lifestyle Asset Intel
yolo tick #3: live liquidity_score + expected_dts (no more constants)
9b63e84518decf451b8ba7eb05c1e3fd84c4759e · 2026-05-09 23:54:24 -0700 · Steve Abrams
Replace the seed's constant 0.78 liquidity / 21 dts with per-asset live
calculations from the trailing 24-month transaction window.
computeLiquidity(stats) — pure function combining:
0.4 * count_factor log-saturating in n_comps over a 20-comp ceiling
0.4 * recency_factor linear decay from newest comp's age over 365 days
0.2 * spread_factor 1 − (Q90−Q10)/avg_gross, gross-normalized so a
$30k asset with $5k spread scores the same as a
$3k asset with $500 spread
computeExpectedDts(liquidity) — inverse of liquidity, clamped [7, 120]:
liquidity=1.0 → 10 days, =0.5 → 35 days, =0 → 60 days
valueAsset() and listAssets() both compute these live and override the
snapshot's stored constants before returning. listAssets does it in SQL
via a second LATERAL aggregating PERCENTILE_CONT(0.9/0.1) and AVG —
mirrors the JS quantile() helper so server + DB agree on numbers.
Across the 15 seeded assets: liquidity ranges 0.349–0.741, dts 23–43.
The console grid's sort-by-liquidity now produces meaningful order; the
asset detail page shows real, per-config days-to-sell.
8 new pure-function tests in tests/valuation.test.js (29/29 total green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M lib/valuation.jsM tests/valuation.test.js
Diff
commit 9b63e84518decf451b8ba7eb05c1e3fd84c4759e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 9 23:54:24 2026 -0700
yolo tick #3: live liquidity_score + expected_dts (no more constants)
Replace the seed's constant 0.78 liquidity / 21 dts with per-asset live
calculations from the trailing 24-month transaction window.
computeLiquidity(stats) — pure function combining:
0.4 * count_factor log-saturating in n_comps over a 20-comp ceiling
0.4 * recency_factor linear decay from newest comp's age over 365 days
0.2 * spread_factor 1 − (Q90−Q10)/avg_gross, gross-normalized so a
$30k asset with $5k spread scores the same as a
$3k asset with $500 spread
computeExpectedDts(liquidity) — inverse of liquidity, clamped [7, 120]:
liquidity=1.0 → 10 days, =0.5 → 35 days, =0 → 60 days
valueAsset() and listAssets() both compute these live and override the
snapshot's stored constants before returning. listAssets does it in SQL
via a second LATERAL aggregating PERCENTILE_CONT(0.9/0.1) and AVG —
mirrors the JS quantile() helper so server + DB agree on numbers.
Across the 15 seeded assets: liquidity ranges 0.349–0.741, dts 23–43.
The console grid's sort-by-liquidity now produces meaningful order; the
asset detail page shows real, per-config days-to-sell.
8 new pure-function tests in tests/valuation.test.js (29/29 total green).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/valuation.js | 145 +++++++++++++++++++++++++++++++++++++++++++++---
tests/valuation.test.js | 55 +++++++++++++++++-
2 files changed, 191 insertions(+), 9 deletions(-)
diff --git a/lib/valuation.js b/lib/valuation.js
index c81945e..48d2848 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -73,6 +73,37 @@ async function valueAsset(slug) {
1
);
+ // Live-compute liquidity + expected_dts from comps in the trailing 24mo
+ // window. Mirrors the logic in listAssets() — see computeLiquidity below.
+ const recent = comps.filter((c) => {
+ if (!c.transacted_at) return false;
+ const t = new Date(c.transacted_at).getTime();
+ return isFinite(t) && (Date.now() - t) <= 24 * 30 * 86400000;
+ });
+ const grossPrices = recent
+ .map((c) => parseFloat(c.gross_transaction_price))
+ .filter(Number.isFinite)
+ .sort((a, b) => a - b);
+ const avg_gross = grossPrices.length
+ ? grossPrices.reduce((a, b) => a + b, 0) / grossPrices.length
+ : null;
+ const spread = grossPrices.length >= 2
+ ? quantile(grossPrices, 0.9) - quantile(grossPrices, 0.1)
+ : null;
+ const newest_comp = recent.length
+ ? recent.reduce((acc, c) => {
+ const t = new Date(c.transacted_at).getTime();
+ return (acc === null || t > acc) ? t : acc;
+ }, null)
+ : null;
+ const liquidity_score = computeLiquidity({
+ n_comps: recent.length,
+ newest_comp,
+ avg_gross,
+ spread
+ });
+ const expected_dts = computeExpectedDts(liquidity_score);
+
return {
asset: {
slug: asset.slug,
@@ -91,9 +122,10 @@ async function valueAsset(slug) {
q10: numberOrNull(snapshot.q10),
q50: numberOrNull(snapshot.q50),
q90: numberOrNull(snapshot.q90),
- liquidity_score: numberOrNull(snapshot.liquidity_score),
- expected_dts: snapshot.expected_dts,
- // Confidence is recomputed from the live breakdown — read-only enrichment.
+ // liquidity + dts are recomputed from the live 24mo comp window —
+ // read-only enrichment, just like confidence below.
+ liquidity_score: liquidity_score,
+ expected_dts: expected_dts,
confidence: confidence,
auth_risk: numberOrNull(snapshot.auth_risk),
comp_count: snapshot.comp_count,
@@ -230,14 +262,23 @@ function computeBreakdown({ asset, comps, snapshot, sourceWeights }) {
}
async function listAssets() {
- // Joins back to the latest snapshot per asset for the console grid.
- return many(
+ // Joins each asset to its latest snapshot AND a 24-month transaction
+ // window for live-computed liquidity/dts. The snapshot's stored
+ // liquidity_score/expected_dts are constants in v0 seed data; we
+ // override with live values before returning so the grid sorts and
+ // filters reflect actual signal, not seed defaults.
+ const rows = await many(
`SELECT ca.id, ca.slug,
mf.name AS family_name, mf.slug AS family_slug,
b.name AS brand_name, b.slug AS brand_slug,
ca.size, ca.material, ca.color, ca.hardware, ca.region,
- vs.q10, vs.q50, vs.q90, vs.liquidity_score, vs.confidence,
- vs.expected_dts, vs.comp_count, vs.as_of
+ vs.q10, vs.q50, vs.q90, vs.liquidity_score AS seed_liquidity_score,
+ vs.confidence, vs.expected_dts AS seed_expected_dts,
+ vs.comp_count, vs.as_of,
+ stats.n_comps,
+ stats.newest_comp,
+ stats.avg_gross,
+ stats.spread
FROM canonical_assets ca
JOIN model_families mf ON mf.id = ca.model_family_id
JOIN brands b ON b.id = mf.brand_id
@@ -246,8 +287,79 @@ async function listAssets() {
WHERE vs.canonical_asset_id = ca.id
ORDER BY vs.as_of DESC LIMIT 1
) vs ON true
+ LEFT JOIN LATERAL (
+ SELECT COUNT(*)::int AS n_comps,
+ MAX(t.transacted_at) AS newest_comp,
+ AVG(t.gross_transaction_price)::numeric AS avg_gross,
+ (PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY t.gross_transaction_price)
+ - PERCENTILE_CONT(0.1) WITHIN GROUP (ORDER BY t.gross_transaction_price))::numeric AS spread
+ FROM transactions t
+ WHERE t.canonical_asset_id = ca.id
+ AND t.transacted_at > now() - interval '24 months'
+ ) stats ON true
ORDER BY b.name, mf.name, ca.size, ca.material, ca.color`
);
+
+ return rows.map((r) => {
+ const stats = {
+ n_comps: r.n_comps || 0,
+ newest_comp: r.newest_comp,
+ avg_gross: numberOrNull(r.avg_gross),
+ spread: numberOrNull(r.spread)
+ };
+ const liquidity = computeLiquidity(stats);
+ const expected_dts = computeExpectedDts(liquidity);
+ // Strip the helper columns + replace with live values.
+ const { n_comps, newest_comp, avg_gross, spread,
+ seed_liquidity_score, seed_expected_dts, ...rest } = r;
+ return {
+ ...rest,
+ liquidity_score: liquidity,
+ expected_dts
+ };
+ });
+}
+
+// computeLiquidity — pure function combining comp depth, recency, and
+// price-spread tightness into a 0–1 score. Heuristic (no embeddings yet).
+//
+// stats: { n_comps, newest_comp (Date|string|null), avg_gross, spread }
+//
+// Weights: 0.4 count + 0.4 recency + 0.2 spread. Spread term is gross-
+// normalized (spread / avg_gross), so a $30k asset with $5k Q10–Q90 gap
+// scores the same liquidity as a $3k asset with $500 gap.
+function computeLiquidity(stats) {
+ if (!stats || !stats.n_comps) return 0;
+
+ const count_factor = clamp(Math.log1p(stats.n_comps) / Math.log1p(20), 0, 1);
+
+ let recency_factor = 0;
+ if (stats.newest_comp) {
+ const t = new Date(stats.newest_comp).getTime();
+ if (isFinite(t)) {
+ const days_old = Math.max(0, (Date.now() - t) / 86400000);
+ recency_factor = clamp(1 - days_old / 365, 0, 1);
+ }
+ }
+
+ let spread_factor = 0;
+ if (stats.avg_gross != null && stats.avg_gross > 0 && stats.spread != null) {
+ const norm_spread = stats.spread / stats.avg_gross;
+ spread_factor = clamp(1 - norm_spread, 0, 1);
+ } else if (stats.n_comps > 0) {
+ // Single-comp or zero-spread case: neutral middle value.
+ spread_factor = 0.5;
+ }
+
+ const liq = 0.4 * count_factor + 0.4 * recency_factor + 0.2 * spread_factor;
+ return clamp(+liq.toFixed(3), 0, 1);
+}
+
+// computeExpectedDts — inverse of liquidity, capped to [7, 120] days.
+// liquidity=1.0 → 10 days; liquidity=0.5 → 35 days; liquidity=0 → 60 days.
+function computeExpectedDts(liquidity) {
+ const l = clamp(numberOrNull(liquidity) || 0, 0, 1);
+ return Math.round(clamp(60 - l * 50, 7, 120));
}
async function getIndex(slug) {
@@ -288,4 +400,21 @@ function clamp(v, lo, hi) {
return v;
}
-module.exports = { valueAsset, listAssets, getIndex, computeBreakdown };
+// Linear-interpolated quantile on a SORTED-ASCENDING numeric array.
+// Mirrors PostgreSQL PERCENTILE_CONT semantics so server + DB agree.
+function quantile(sorted, q) {
+ if (!sorted || !sorted.length) return null;
+ if (sorted.length === 1) return sorted[0];
+ const pos = (sorted.length - 1) * q;
+ const base = Math.floor(pos);
+ const rest = pos - base;
+ if (sorted[base + 1] !== undefined) {
+ return sorted[base] + rest * (sorted[base + 1] - sorted[base]);
+ }
+ return sorted[base];
+}
+
+module.exports = {
+ valueAsset, listAssets, getIndex,
+ computeBreakdown, computeLiquidity, computeExpectedDts, quantile
+};
diff --git a/tests/valuation.test.js b/tests/valuation.test.js
index e3d8ad6..32289b3 100644
--- a/tests/valuation.test.js
+++ b/tests/valuation.test.js
@@ -13,7 +13,10 @@ process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';
const { test, after } = require('node:test');
const assert = require('node:assert/strict');
-const { computeBreakdown, valueAsset } = require('../lib/valuation');
+const {
+ computeBreakdown, valueAsset,
+ computeLiquidity, computeExpectedDts, quantile
+} = require('../lib/valuation');
const { pool } = require('../lib/db');
const BREAKDOWN_KEYS = [
@@ -89,6 +92,56 @@ test('valueAsset on the seeded slug returns the contract shape with live breakdo
}
});
+test('computeLiquidity: zero comps → 0', () => {
+ assert.equal(computeLiquidity({ n_comps: 0 }), 0);
+ assert.equal(computeLiquidity(null), 0);
+});
+
+test('computeLiquidity: many fresh tight-spread comps → near 1', () => {
+ const now = Date.now();
+ const liq = computeLiquidity({
+ n_comps: 18,
+ newest_comp: new Date(now - 7 * 86400000).toISOString(),
+ avg_gross: 22000,
+ spread: 1100 // 5% gross-normalized spread → very tight
+ });
+ assert.ok(liq >= 0.85, `expected liquidity >= 0.85 for hot config, got ${liq}`);
+ assert.ok(liq <= 1.0);
+});
+
+test('computeLiquidity: stale wide-spread → low score', () => {
+ const liq = computeLiquidity({
+ n_comps: 2,
+ newest_comp: new Date(Date.now() - 600 * 86400000).toISOString(),
+ avg_gross: 20000,
+ spread: 18000 // 90% gross-normalized → very wide
+ });
+ assert.ok(liq <= 0.30, `expected liquidity <= 0.30 for cold config, got ${liq}`);
+});
+
+test('computeExpectedDts: liquidity=1 → 10 days', () => {
+ assert.equal(computeExpectedDts(1.0), 10);
+});
+test('computeExpectedDts: liquidity=0 → 60 days', () => {
+ assert.equal(computeExpectedDts(0), 60);
+});
+test('computeExpectedDts: liquidity=0.5 → 35 days', () => {
+ assert.equal(computeExpectedDts(0.5), 35);
+});
+test('computeExpectedDts: clamped to [7, 120]', () => {
+ assert.ok(computeExpectedDts(2) >= 7);
+ assert.ok(computeExpectedDts(-1) <= 120);
+});
+
+test('quantile: linear interpolation matches PG PERCENTILE_CONT semantics', () => {
+ const arr = [10, 20, 30, 40, 50];
+ assert.equal(quantile(arr, 0.5), 30); // exact middle
+ assert.equal(quantile(arr, 0.0), 10);
+ assert.equal(quantile(arr, 1.0), 50);
+ assert.equal(quantile(arr, 0.25), 20);
+ assert.equal(quantile([], 0.5), null);
+});
+
after(async () => {
await pool.end();
});
← ea1adfb yolo tick #2: portfolio nav link + view styling
·
back to Lifestyle Asset Intel
·
yolo tick #4: image_assets + image_embeddings (prep for v0.3 660aa14 →