← back to Lifestyle Asset Intel

tests/valuation.test.js

148 lines

// valuation.test.js — focused tests for the compositional confidence calc.
//
// Calls computeBreakdown directly so we don't have to boot Express. The DB
// pool is touched only by valueAsset(), which we use for the live happy path
// against the seeded slug 'birkin-30-togo-gold-ghw-us'.

'use strict';

require('dotenv').config();
process.env.PGHOST = process.env.PGHOST || '/tmp';
process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';

const { test, after } = require('node:test');
const assert = require('node:assert/strict');

const {
  computeBreakdown, valueAsset,
  computeLiquidity, computeExpectedDts, quantile
} = require('../lib/valuation');
const { pool } = require('../lib/db');

const BREAKDOWN_KEYS = [
  'source_quality_weight',
  'comparable_similarity_weight',
  'sample_depth_weight',
  'recency_weight',
  'image_match_weight',
  'authenticity_risk_penalty',
  'condition_uncertainty_penalty',
  'region_gap_penalty',
  'fee_model_uncertainty_penalty'
];

test('computeBreakdown is callable and returns all 9 keys', () => {
  const out = computeBreakdown({
    asset: { region: 'US' },
    comps: [],
    snapshot: null,
    sourceWeights: {}
  });
  assert.equal(typeof out, 'object');
  for (const k of BREAKDOWN_KEYS) {
    assert.ok(k in out, `missing key ${k}`);
    assert.equal(typeof out[k], 'number', `${k} should be a number`);
  }
});

test('happy path with 5 fresh tier-1/2 comps yields confidence in [0.55, 0.95]', () => {
  const now = Date.now();
  const recent = (daysAgo) => new Date(now - daysAgo * 86400000).toISOString();
  const comps = [
    { source_tier: 1, source_weight: 1.000, transacted_at: recent(5),  condition_grade: 5, region: 'US' },
    { source_tier: 1, source_weight: 1.000, transacted_at: recent(20), condition_grade: 5, region: 'US' },
    { source_tier: 2, source_weight: 0.800, transacted_at: recent(35), condition_grade: 4, region: 'US' },
    { source_tier: 2, source_weight: 0.700, transacted_at: recent(50), condition_grade: 4, region: 'US' },
    { source_tier: 2, source_weight: 0.700, transacted_at: recent(70), condition_grade: 4, region: 'US' }
  ];
  const breakdown = computeBreakdown({
    asset: { region: 'US' },
    comps,
    snapshot: { auth_risk: 0.05 },
    sourceWeights: { 1: 1.0, 2: 0.75 }
  });
  const sum = Object.values(breakdown).reduce((a, b) => a + b, 0);
  const confidence = Math.max(0, Math.min(1, sum));
  assert.ok(confidence >= 0.55, `confidence ${confidence} should be >= 0.55`);
  assert.ok(confidence <= 0.95, `confidence ${confidence} should be <= 0.95`);
});

test('zero comps yields confidence <= 0.30', () => {
  const breakdown = computeBreakdown({
    asset: { region: 'US' },
    comps: [],
    snapshot: { auth_risk: 0.10 },
    sourceWeights: {}
  });
  const sum = Object.values(breakdown).reduce((a, b) => a + b, 0);
  const confidence = Math.max(0, Math.min(1, sum));
  assert.ok(confidence <= 0.30, `confidence ${confidence} should be <= 0.30 with no comps`);
});

test('valueAsset on the seeded slug returns the contract shape with live breakdown', async () => {
  const out = await valueAsset('birkin-30-togo-gold-ghw-us');
  assert.ok(out, 'asset present');
  assert.ok(out.confidence_breakdown, 'confidence_breakdown present');
  for (const k of BREAKDOWN_KEYS) {
    assert.ok(k in out.confidence_breakdown, `live breakdown missing ${k}`);
  }
  if (out.latest_snapshot) {
    const c = out.latest_snapshot.confidence;
    assert.ok(c >= 0 && c <= 1, `confidence ${c} should be clamped [0,1]`);
  }
});

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();
});