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