[object Object]

← 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

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 →