← back to Lifestyle Asset Intel
real confidence calc + sparklines + portfolio CRUD (Max It batch)
917d044322fab3a6e0f016efb089fd0eb5af74ae · 2026-05-09 23:44:35 -0700 · Steve Abrams
Three parallel improvements landed via the Max It dispatcher:
1. lib/valuation.js — replace constant-stub confidence_breakdown with a
compositional calculator (computeBreakdown). Source quality weights from
the joined sources.weight column (tier fallback {1:1.0, 2:0.7, 3:0.4,
4:0.2}); sample depth log-saturates over 20 comps; recency decays
linearly over a year on the newest comp; auth/condition/region risk
penalties are bounded. Latest_snapshot.confidence is recomputed from
the live breakdown sum, clamped [0,1]. computeBreakdown is exported as
a pure function so tests don't need to boot Express.
2. SVG sparklines — new views/partials/sparkline.ejs renders inline path +
circles from {as_of, value} points. Integrated into asset.ejs (price
trend over comps' transacted_at → gross) and index_detail.ejs (time
series of index points). Pure SSR, no client-side chart library. CSS
adds .spark { color: var(--accent); } and .trend section spacing.
3. Portfolio CRUD — routes/api.js gains GET/POST/PATCH/DELETE on
/api/portfolio with email-format validation, asset_slug existence check,
parameterized queries, and LATERAL-joined latest snapshots so each
holding ships with current Q50 + unrealized P&L. routes/console.js adds
/portfolio?owner= page using a shared lib/portfolio.js helper. New
views/portfolio.ejs renders the holdings table + add-holding form +
per-row remove form with totals row at bottom. Datalist of all asset
slugs powers the add-form autocomplete.
Tests: 21 total now (7 smoke + 5 valuation + 9 portfolio); all green.
package.json test script switched to glob tests/*.test.js so future test
files auto-pick up. tests/valuation.test.js needed dotenv + PGHOST=/tmp
guards to match smoke.test.js's local-socket pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A lib/portfolio.jsM lib/valuation.jsM package.jsonM public/css/app.cssM routes/api.jsM routes/console.jsA tests/portfolio.test.jsA tests/valuation.test.jsM views/asset.ejsM views/index_detail.ejsA views/partials/sparkline.ejsA views/portfolio.ejs
Diff
commit 917d044322fab3a6e0f016efb089fd0eb5af74ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 9 23:44:35 2026 -0700
real confidence calc + sparklines + portfolio CRUD (Max It batch)
Three parallel improvements landed via the Max It dispatcher:
1. lib/valuation.js — replace constant-stub confidence_breakdown with a
compositional calculator (computeBreakdown). Source quality weights from
the joined sources.weight column (tier fallback {1:1.0, 2:0.7, 3:0.4,
4:0.2}); sample depth log-saturates over 20 comps; recency decays
linearly over a year on the newest comp; auth/condition/region risk
penalties are bounded. Latest_snapshot.confidence is recomputed from
the live breakdown sum, clamped [0,1]. computeBreakdown is exported as
a pure function so tests don't need to boot Express.
2. SVG sparklines — new views/partials/sparkline.ejs renders inline path +
circles from {as_of, value} points. Integrated into asset.ejs (price
trend over comps' transacted_at → gross) and index_detail.ejs (time
series of index points). Pure SSR, no client-side chart library. CSS
adds .spark { color: var(--accent); } and .trend section spacing.
3. Portfolio CRUD — routes/api.js gains GET/POST/PATCH/DELETE on
/api/portfolio with email-format validation, asset_slug existence check,
parameterized queries, and LATERAL-joined latest snapshots so each
holding ships with current Q50 + unrealized P&L. routes/console.js adds
/portfolio?owner= page using a shared lib/portfolio.js helper. New
views/portfolio.ejs renders the holdings table + add-holding form +
per-row remove form with totals row at bottom. Datalist of all asset
slugs powers the add-form autocomplete.
Tests: 21 total now (7 smoke + 5 valuation + 9 portfolio); all green.
package.json test script switched to glob tests/*.test.js so future test
files auto-pick up. tests/valuation.test.js needed dotenv + PGHOST=/tmp
guards to match smoke.test.js's local-socket pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/portfolio.js | 159 +++++++++++++++++++++++++++++++++++++++
lib/valuation.js | 173 ++++++++++++++++++++++++++++++++++++++++---
package.json | 2 +-
public/css/app.css | 4 +
routes/api.js | 74 +++++++++++++++---
routes/console.js | 82 ++++++++++++++++++++
tests/portfolio.test.js | 122 ++++++++++++++++++++++++++++++
tests/valuation.test.js | 94 +++++++++++++++++++++++
views/asset.ejs | 11 +++
views/index_detail.ejs | 3 +
views/partials/sparkline.ejs | 56 ++++++++++++++
views/portfolio.ejs | 132 +++++++++++++++++++++++++++++++++
12 files changed, 891 insertions(+), 21 deletions(-)
diff --git a/lib/portfolio.js b/lib/portfolio.js
new file mode 100644
index 0000000..c7d30a4
--- /dev/null
+++ b/lib/portfolio.js
@@ -0,0 +1,159 @@
+// portfolio.js — shared helpers for portfolio_holdings.
+//
+// The enriched-list query joins each holding to its canonical asset, brand,
+// family, and the latest valuation_snapshot via a LATERAL subquery (mirrors
+// lib/valuation.js listAssets()), then computes unrealized_pl and pl_pct in
+// JS so callers (API + console) get identical shapes.
+
+const { pool } = require('./db');
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function isValidEmail(s) {
+ return typeof s === 'string' && EMAIL_RE.test(s.trim());
+}
+
+async function findAssetIdBySlug(slug) {
+ if (typeof slug !== 'string' || !slug.trim()) return null;
+ const r = await pool.query(
+ `SELECT id FROM canonical_assets WHERE slug = $1`,
+ [slug.trim()]
+ );
+ return r.rows[0] ? r.rows[0].id : null;
+}
+
+async function listHoldings(ownerEmail) {
+ const r = await pool.query(
+ `SELECT ph.id,
+ ph.owner_email,
+ ph.canonical_asset_id,
+ ph.acquired_at,
+ ph.acquisition_basis,
+ ph.notes,
+ ph.created_at,
+ ph.updated_at,
+ ca.slug AS asset_slug,
+ mf.name AS family,
+ mf.slug AS family_slug,
+ b.name AS brand,
+ b.slug AS brand_slug,
+ ca.size AS size,
+ ca.material AS material,
+ ca.color AS color,
+ vs.q10 AS q10,
+ vs.q50 AS q50,
+ vs.q90 AS q90,
+ vs.as_of AS snapshot_as_of
+ FROM portfolio_holdings ph
+ JOIN canonical_assets ca ON ca.id = ph.canonical_asset_id
+ JOIN model_families mf ON mf.id = ca.model_family_id
+ JOIN brands b ON b.id = mf.brand_id
+ LEFT JOIN LATERAL (
+ SELECT q10, q50, q90, as_of
+ FROM valuation_snapshots vs
+ WHERE vs.canonical_asset_id = ca.id
+ ORDER BY vs.as_of DESC
+ LIMIT 1
+ ) vs ON true
+ WHERE ph.owner_email = $1
+ ORDER BY ph.acquired_at DESC NULLS LAST, ph.created_at DESC`,
+ [ownerEmail]
+ );
+ return r.rows.map(enrich);
+}
+
+function enrich(row) {
+ const basis = numOrNull(row.acquisition_basis);
+ const q50 = numOrNull(row.q50);
+ let unrealized_pl = null;
+ let pl_pct = null;
+ if (basis !== null && q50 !== null) {
+ unrealized_pl = +(q50 - basis).toFixed(2);
+ if (basis !== 0) pl_pct = +((q50 - basis) / basis).toFixed(4);
+ }
+ let days_held = null;
+ if (row.acquired_at) {
+ const acq = new Date(row.acquired_at);
+ if (!isNaN(acq.getTime())) {
+ days_held = Math.max(0, Math.floor((Date.now() - acq.getTime()) / 86400000));
+ }
+ }
+ return {
+ ...row,
+ acquisition_basis: basis,
+ q10: numOrNull(row.q10),
+ q50,
+ q90: numOrNull(row.q90),
+ unrealized_pl,
+ pl_pct,
+ days_held
+ };
+}
+
+function numOrNull(v) {
+ if (v === null || v === undefined) return null;
+ const n = typeof v === 'string' ? parseFloat(v) : v;
+ return Number.isFinite(n) ? n : null;
+}
+
+async function insertHolding({ owner_email, asset_slug, acquired_at, acquisition_basis, notes }) {
+ const assetId = await findAssetIdBySlug(asset_slug);
+ if (!assetId) {
+ const err = new Error('invalid_asset_slug');
+ err.code = 'invalid_asset_slug';
+ throw err;
+ }
+ const r = await pool.query(
+ `INSERT INTO portfolio_holdings (owner_email, canonical_asset_id, acquired_at, acquisition_basis, notes)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *`,
+ [
+ owner_email.trim(),
+ assetId,
+ acquired_at || null,
+ acquisition_basis === undefined || acquisition_basis === null || acquisition_basis === '' ? null : acquisition_basis,
+ notes || null
+ ]
+ );
+ return r.rows[0];
+}
+
+async function patchHolding(id, fields) {
+ const allowed = ['acquired_at', 'acquisition_basis', 'notes'];
+ const sets = [];
+ const vals = [];
+ let i = 1;
+ for (const key of allowed) {
+ if (Object.prototype.hasOwnProperty.call(fields, key)) {
+ sets.push(`${key} = $${i++}`);
+ vals.push(fields[key] === '' ? null : fields[key]);
+ }
+ }
+ if (!sets.length) return { error: 'no_fields_to_update' };
+ sets.push(`updated_at = now()`);
+ vals.push(id);
+ const r = await pool.query(
+ `UPDATE portfolio_holdings SET ${sets.join(', ')} WHERE id = $${i} RETURNING *`,
+ vals
+ );
+ if (!r.rows[0]) return { error: 'not_found' };
+ return { row: r.rows[0] };
+}
+
+async function deleteHolding(id) {
+ const r = await pool.query(
+ `DELETE FROM portfolio_holdings WHERE id = $1 RETURNING id`,
+ [id]
+ );
+ return r.rowCount > 0;
+}
+
+module.exports = {
+ EMAIL_RE,
+ isValidEmail,
+ findAssetIdBySlug,
+ listHoldings,
+ insertHolding,
+ patchHolding,
+ deleteHolding
+};
diff --git a/lib/valuation.js b/lib/valuation.js
index 1224652..c81945e 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -1,15 +1,19 @@
-// valuation.js — v0 stub valuation engine.
+// valuation.js — v0 valuation engine with compositional confidence calc.
//
// This file is the contract that future ML replaces in v0.5+. It computes a
// blueprint-shaped { asset, latest_snapshot, comps, confidence_breakdown }
-// response from the seeded transactions table using simple SQL aggregations
-// — no models, no embeddings, no calibration.
+// response from the seeded transactions table using simple SQL aggregations.
//
-// When real valuation lands, ONLY the body of `valueAsset()` should change;
-// the response shape is the public API contract.
+// In v0.2 the constant-stub `confidence_breakdown` was replaced with a real
+// compositional calculator (`computeBreakdown`) that weighs source quality,
+// sample depth, comp recency, and authenticity/region/condition risks. The
+// public response shape is unchanged — only the BODY of the calculation.
const { many, one } = require('./db');
+// Tier-tier fallback weights when a source.weight column is unreachable.
+const TIER_FALLBACK = { 1: 1.0, 2: 0.7, 3: 0.4, 4: 0.2 };
+
async function valueAsset(slug) {
const asset = await one(
`SELECT ca.*, mf.name AS family_name, mf.slug AS family_slug,
@@ -23,7 +27,8 @@ async function valueAsset(slug) {
if (!asset) return null;
const comps = await many(
- `SELECT t.*, s.slug AS source_slug, s.name AS source_name, s.tier AS source_tier
+ `SELECT t.*, s.slug AS source_slug, s.name AS source_name,
+ s.tier AS source_tier, s.weight AS source_weight
FROM transactions t
JOIN sources s ON s.id = t.source_id
WHERE t.canonical_asset_id = $1
@@ -41,6 +46,33 @@ async function valueAsset(slug) {
[asset.id]
);
+ // Build a tier→weight lookup from the joined comps; falls back to constants.
+ const sourceWeights = {};
+ for (const c of comps) {
+ if (c.source_weight != null) {
+ sourceWeights[c.source_tier] = parseFloat(c.source_weight);
+ }
+ }
+
+ const breakdown = computeBreakdown({
+ asset,
+ comps: comps.map((c) => ({
+ source_tier: c.source_tier,
+ source_weight: c.source_weight,
+ transacted_at: c.transacted_at,
+ condition_grade: c.condition_grade,
+ region: c.region
+ })),
+ snapshot,
+ sourceWeights
+ });
+
+ const confidence = clamp(
+ Object.values(breakdown).reduce((a, b) => a + b, 0),
+ 0,
+ 1
+ );
+
return {
asset: {
slug: asset.slug,
@@ -61,7 +93,8 @@ async function valueAsset(slug) {
q90: numberOrNull(snapshot.q90),
liquidity_score: numberOrNull(snapshot.liquidity_score),
expected_dts: snapshot.expected_dts,
- confidence: numberOrNull(snapshot.confidence),
+ // Confidence is recomputed from the live breakdown — read-only enrichment.
+ confidence: confidence,
auth_risk: numberOrNull(snapshot.auth_risk),
comp_count: snapshot.comp_count,
methodology_version: snapshot.methodology_version
@@ -77,7 +110,122 @@ async function valueAsset(slug) {
currency: c.currency,
region: c.region
})),
- confidence_breakdown: snapshot && snapshot.breakdown ? snapshot.breakdown : {}
+ confidence_breakdown: breakdown
+ };
+}
+
+// computeBreakdown — pure function so tests can call it without booting Express.
+//
+// Inputs:
+// asset: { region, ... } (canonical_assets row)
+// comps: [{ source_tier, source_weight, transacted_at,
+// condition_grade, region }, ...]
+// snapshot: { auth_risk, ... } | null (valuation_snapshots row)
+// sourceWeights: { [tier]: weight } (DB-derived; falls back
+// to TIER_FALLBACK below)
+//
+// Returns the 9-key breakdown object. Sum is intentionally NOT clamped here —
+// the caller clamps to [0,1] when assembling latest_snapshot.confidence.
+function computeBreakdown({ asset, comps, snapshot, sourceWeights }) {
+ const compArr = Array.isArray(comps) ? comps : [];
+ const n = compArr.length;
+ const weights = sourceWeights || {};
+
+ // 1. source_quality_weight — weighted avg of source.weight across comps,
+ // scaled to a 0-0.30 contribution band.
+ let source_quality_weight = 0;
+ if (n > 0) {
+ let acc = 0;
+ for (const c of compArr) {
+ let w;
+ if (c.source_weight != null) {
+ w = parseFloat(c.source_weight);
+ } else if (weights[c.source_tier] != null) {
+ w = weights[c.source_tier];
+ } else {
+ w = TIER_FALLBACK[c.source_tier] != null ? TIER_FALLBACK[c.source_tier] : 0.2;
+ }
+ acc += isFinite(w) ? w : 0.2;
+ }
+ const avg = acc / n; // 0..1
+ source_quality_weight = clamp(avg * 0.30, 0, 0.30);
+ }
+
+ // 2. comparable_similarity_weight — constant for v0; embeddings come later.
+ // TODO v0.3: replace with cosine-sim against a reference vector.
+ const comparable_similarity_weight = n > 0 ? 0.18 : 0;
+
+ // 3. sample_depth_weight — log-saturating in comp count, capped at 20 comps.
+ const sample_depth_weight = clamp(
+ (Math.log1p(n) / Math.log1p(20)) * 0.20,
+ 0,
+ 0.20
+ );
+
+ // 4. recency_weight — newest comp's age in days; linear decay over a year.
+ let recency_weight = 0;
+ if (n > 0) {
+ let newest = -Infinity;
+ for (const c of compArr) {
+ if (!c.transacted_at) continue;
+ const t = new Date(c.transacted_at).getTime();
+ if (isFinite(t) && t > newest) newest = t;
+ }
+ if (isFinite(newest)) {
+ const daysOld = Math.max(0, (Date.now() - newest) / 86400000);
+ recency_weight = clamp(0.15 * (1 - daysOld / 365), 0, 0.15);
+ }
+ }
+
+ // 5. image_match_weight — constant for v0; needs image-intake pipeline.
+ // TODO v0.3 image-intake.
+ const image_match_weight = n > 0 ? 0.09 : 0;
+
+ // 6. authenticity_risk_penalty — half of the snapshot's stated auth_risk.
+ let authenticity_risk_penalty = 0;
+ if (snapshot && snapshot.auth_risk != null) {
+ const r = parseFloat(snapshot.auth_risk);
+ if (isFinite(r)) {
+ authenticity_risk_penalty = clamp(-1 * r * 0.5, -0.15, 0);
+ }
+ }
+
+ // 7. condition_uncertainty_penalty — penalize missing or weak grades.
+ let condition_uncertainty_penalty = 0;
+ if (n > 0) {
+ const anyMissing = compArr.some(
+ (c) => c.condition_grade === null || c.condition_grade === undefined
+ );
+ if (anyMissing) {
+ condition_uncertainty_penalty = -0.05;
+ } else {
+ const allHigh = compArr.every((c) => c.condition_grade >= 4);
+ condition_uncertainty_penalty = allHigh ? 0 : -0.02;
+ }
+ condition_uncertainty_penalty = clamp(condition_uncertainty_penalty, -0.10, 0);
+ }
+
+ // 8. region_gap_penalty — 0 if all comps match the asset's region.
+ let region_gap_penalty = 0;
+ if (n > 0 && asset && asset.region) {
+ const allSame = compArr.every((c) => c.region === asset.region);
+ region_gap_penalty = clamp(allSame ? 0 : -0.03, -0.10, 0);
+ }
+
+ // 9. fee_model_uncertainty_penalty — flat haircut until per-source fee
+ // schedules land in v0.4.
+ const fee_model_uncertainty_penalty = -0.01;
+
+ return {
+ 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
};
}
@@ -133,4 +281,11 @@ function numberOrNull(v) {
return Number.isFinite(n) ? n : null;
}
-module.exports = { valueAsset, listAssets, getIndex };
+function clamp(v, lo, hi) {
+ if (!isFinite(v)) return lo;
+ if (v < lo) return lo;
+ if (v > hi) return hi;
+ return v;
+}
+
+module.exports = { valueAsset, listAssets, getIndex, computeBreakdown };
diff --git a/package.json b/package.json
index 8072d05..509f87c 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"schema": "psql $PGDATABASE < db/schema.sql",
"seed": "psql $PGDATABASE < db/seed.sql",
"migrate": "node scripts/migrate.js",
- "test": "node --test tests/smoke.test.js",
+ "test": "node --test tests/*.test.js",
"pm2:start": "pm2 start ecosystem.config.js",
"pm2:stop": "pm2 stop ecosystem.config.js",
"pm2:restart": "pm2 restart ecosystem.config.js"
diff --git a/public/css/app.css b/public/css/app.css
index a2d3a21..e2d4084 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -133,3 +133,7 @@ pre.json { background: var(--card); border: 1px solid var(--line); border-radius
.hamburger { display: inline-flex; }
.grid { grid-template-columns: repeat(min(var(--cols), 2), minmax(0, 1fr)); }
}
+
+/* sparkline + trend section (v0.2) */
+.spark { color: var(--accent); }
+.trend { margin: 1.4rem 0; }
diff --git a/routes/api.js b/routes/api.js
index b71c8ee..da4e9b7 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -1,6 +1,13 @@
const express = require('express');
const { pool } = require('../lib/db');
const { valueAsset, listAssets, getIndex } = require('../lib/valuation');
+const {
+ isValidEmail,
+ listHoldings,
+ insertHolding,
+ patchHolding,
+ deleteHolding
+} = require('../lib/portfolio');
const router = express.Router();
@@ -51,18 +58,63 @@ router.get('/portfolio', async (req, res, next) => {
try {
const owner = req.query.owner;
if (!owner) return res.status(400).json({ error: 'owner_query_required' });
- const r = await pool.query(
- `SELECT ph.*, ca.slug AS asset_slug, mf.name AS family, b.name AS brand
- FROM portfolio_holdings ph
- JOIN canonical_assets ca ON ca.id = ph.canonical_asset_id
- JOIN model_families mf ON mf.id = ca.model_family_id
- JOIN brands b ON b.id = mf.brand_id
- WHERE ph.owner_email = $1
- ORDER BY ph.acquired_at DESC NULLS LAST`,
- [owner]
- );
- res.json({ owner, holdings: r.rows });
+ const holdings = await listHoldings(owner);
+ res.json({ owner, holdings });
} catch (e) { next(e); }
});
+router.post('/portfolio', async (req, res, next) => {
+ try {
+ const { owner_email, asset_slug, acquired_at, acquisition_basis, notes } = req.body || {};
+ if (!isValidEmail(owner_email)) {
+ return res.status(400).json({ error: 'invalid_email' });
+ }
+ if (!asset_slug || typeof asset_slug !== 'string' || !asset_slug.trim()) {
+ return res.status(400).json({ error: 'invalid_asset_slug' });
+ }
+ try {
+ const row = await insertHolding({ owner_email, asset_slug, acquired_at, acquisition_basis, notes });
+ return res.status(201).json(row);
+ } catch (err) {
+ if (err.code === 'invalid_asset_slug') {
+ return res.status(400).json({ error: 'invalid_asset_slug' });
+ }
+ throw err;
+ }
+ } catch (e) { next(e); }
+});
+
+router.patch('/portfolio/:id', async (req, res, next) => {
+ try {
+ const body = req.body || {};
+ const patchable = ['acquired_at', 'acquisition_basis', 'notes'];
+ const fields = {};
+ for (const k of patchable) {
+ if (Object.prototype.hasOwnProperty.call(body, k)) fields[k] = body[k];
+ }
+ if (!Object.keys(fields).length) {
+ return res.status(400).json({ error: 'no_fields_to_update' });
+ }
+ const out = await patchHolding(req.params.id, fields);
+ if (out.error === 'not_found') return res.status(404).json({ error: 'not_found' });
+ if (out.error) return res.status(400).json({ error: out.error });
+ res.json(out.row);
+ } catch (e) {
+ // invalid uuid → pg throws 22P02; surface as 404 for clean DELETE-then-404 contract too
+ if (e && e.code === '22P02') return res.status(404).json({ error: 'not_found' });
+ next(e);
+ }
+});
+
+router.delete('/portfolio/:id', async (req, res, next) => {
+ try {
+ const ok = await deleteHolding(req.params.id);
+ if (!ok) return res.status(404).json({ error: 'not_found' });
+ res.status(204).end();
+ } catch (e) {
+ if (e && e.code === '22P02') return res.status(404).json({ error: 'not_found' });
+ next(e);
+ }
+});
+
module.exports = router;
diff --git a/routes/console.js b/routes/console.js
index e68be7d..f2a3ff7 100644
--- a/routes/console.js
+++ b/routes/console.js
@@ -1,5 +1,11 @@
const express = require('express');
const { listAssets, valueAsset, getIndex } = require('../lib/valuation');
+const {
+ isValidEmail,
+ listHoldings,
+ insertHolding,
+ deleteHolding
+} = require('../lib/portfolio');
const router = express.Router();
@@ -42,4 +48,80 @@ router.get('/indices/:slug', async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/portfolio', async (req, res, next) => {
+ try {
+ const owner = (req.query.owner || '').trim();
+ const assets = await listAssets();
+ if (!owner) {
+ return res.render('portfolio', {
+ title: 'Portfolio',
+ owner: '',
+ holdings: [],
+ assets,
+ flash: req.query.flash || null,
+ path: req.path
+ });
+ }
+ if (!isValidEmail(owner)) {
+ return res.status(400).render('portfolio', {
+ title: 'Portfolio',
+ owner,
+ holdings: [],
+ assets,
+ flash: 'invalid_email',
+ path: req.path
+ });
+ }
+ const holdings = await listHoldings(owner);
+ res.render('portfolio', {
+ title: `Portfolio — ${owner}`,
+ owner,
+ holdings,
+ assets,
+ flash: req.query.flash || null,
+ path: req.path
+ });
+ } catch (e) { next(e); }
+});
+
+router.post('/portfolio/add', async (req, res, next) => {
+ try {
+ const { owner_email, asset_slug, acquired_at, acquisition_basis, notes } = req.body || {};
+ const ownerOut = (owner_email || '').trim();
+ if (!isValidEmail(ownerOut)) {
+ return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_email`);
+ }
+ if (!asset_slug || typeof asset_slug !== 'string' || !asset_slug.trim()) {
+ return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_asset_slug`);
+ }
+ try {
+ await insertHolding({
+ owner_email: ownerOut,
+ asset_slug,
+ acquired_at: acquired_at || null,
+ acquisition_basis: acquisition_basis === '' ? null : acquisition_basis,
+ notes: notes || null
+ });
+ } catch (err) {
+ if (err.code === 'invalid_asset_slug') {
+ return res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=invalid_asset_slug`);
+ }
+ throw err;
+ }
+ res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=added`);
+ } catch (e) { next(e); }
+});
+
+router.post('/portfolio/remove/:id', async (req, res, next) => {
+ try {
+ const ownerOut = (req.body && req.body.owner_email) ? req.body.owner_email.trim() : '';
+ try {
+ await deleteHolding(req.params.id);
+ } catch (err) {
+ if (!(err && err.code === '22P02')) throw err;
+ }
+ res.redirect(302, `/portfolio?owner=${encodeURIComponent(ownerOut)}&flash=removed`);
+ } catch (e) { next(e); }
+});
+
module.exports = router;
diff --git a/tests/portfolio.test.js b/tests/portfolio.test.js
new file mode 100644
index 0000000..15d256f
--- /dev/null
+++ b/tests/portfolio.test.js
@@ -0,0 +1,122 @@
+// portfolio.test.js — exercises the /api/portfolio CRUD surface against a real DB.
+//
+// Requires the same seeded DB as smoke.test.js. Uses a unique owner email per
+// run (test+<ts>@example.com) so reruns never collide.
+
+'use strict';
+
+const { test, after } = require('node:test');
+const assert = require('node:assert/strict');
+
+process.env.NODE_ENV = process.env.NODE_ENV || 'test';
+process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';
+
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+let baseUrl;
+
+function ready() {
+ return new Promise((resolve) => {
+ server = app.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ baseUrl = `http://127.0.0.1:${port}`;
+ resolve();
+ });
+ });
+}
+
+async function req(method, path, body) {
+ const init = { method, headers: {} };
+ if (body !== undefined) {
+ init.headers['content-type'] = 'application/json';
+ init.body = JSON.stringify(body);
+ }
+ const r = await fetch(baseUrl + path, init);
+ const text = await r.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch (e) { /* not json (e.g. 204) */ }
+ return { status: r.status, json, text };
+}
+
+const OWNER = `test+${Date.now()}@example.com`;
+const ASSET = 'birkin-30-togo-gold-ghw-us';
+let createdId = null;
+
+test('boots for portfolio tests', async () => {
+ await ready();
+});
+
+test('GET /api/portfolio without owner -> 400', async () => {
+ const r = await req('GET', '/api/portfolio');
+ assert.equal(r.status, 400);
+ assert.equal(r.json.error, 'owner_query_required');
+});
+
+test('POST /api/portfolio with bad email -> 400 invalid_email', async () => {
+ const r = await req('POST', '/api/portfolio', { owner_email: 'not-an-email', asset_slug: ASSET });
+ assert.equal(r.status, 400);
+ assert.equal(r.json.error, 'invalid_email');
+});
+
+test('POST /api/portfolio with bogus asset_slug -> 400 invalid_asset_slug', async () => {
+ const r = await req('POST', '/api/portfolio', { owner_email: OWNER, asset_slug: 'not-a-real-slug-xyz' });
+ assert.equal(r.status, 400);
+ assert.equal(r.json.error, 'invalid_asset_slug');
+});
+
+test('POST /api/portfolio happy path -> 201 + id', async () => {
+ const r = await req('POST', '/api/portfolio', {
+ owner_email: OWNER,
+ asset_slug: ASSET,
+ acquired_at: '2024-01-15',
+ acquisition_basis: 9000,
+ notes: 'test fixture'
+ });
+ assert.equal(r.status, 201);
+ assert.ok(r.json.id, 'returned row has id');
+ assert.equal(r.json.owner_email, OWNER);
+ createdId = r.json.id;
+});
+
+test('GET /api/portfolio?owner=... lists the new row with unrealized_pl numeric', async () => {
+ const r = await req('GET', `/api/portfolio?owner=${encodeURIComponent(OWNER)}`);
+ assert.equal(r.status, 200);
+ assert.equal(r.json.owner, OWNER);
+ assert.ok(Array.isArray(r.json.holdings));
+ const found = r.json.holdings.find((h) => h.id === createdId);
+ assert.ok(found, 'new holding present in list');
+ assert.equal(found.asset_slug, ASSET);
+ assert.equal(found.brand, 'Hermès');
+ assert.equal(typeof found.unrealized_pl, 'number', 'unrealized_pl is numeric');
+ assert.equal(typeof found.pl_pct, 'number', 'pl_pct is numeric');
+});
+
+test('PATCH /api/portfolio/:id with empty body -> 400', async () => {
+ const r = await req('PATCH', `/api/portfolio/${createdId}`, {});
+ assert.equal(r.status, 400);
+ assert.equal(r.json.error, 'no_fields_to_update');
+});
+
+test('PATCH /api/portfolio/:id with acquisition_basis -> 200 updated', async () => {
+ const r = await req('PATCH', `/api/portfolio/${createdId}`, { acquisition_basis: 10000 });
+ assert.equal(r.status, 200);
+ assert.equal(r.json.id, createdId);
+ assert.equal(parseFloat(r.json.acquisition_basis), 10000);
+});
+
+test('DELETE /api/portfolio/:id -> 204', async () => {
+ const r = await req('DELETE', `/api/portfolio/${createdId}`);
+ assert.equal(r.status, 204);
+});
+
+test('DELETE /api/portfolio/:id again -> 404', async () => {
+ const r = await req('DELETE', `/api/portfolio/${createdId}`);
+ assert.equal(r.status, 404);
+});
+
+after(async () => {
+ if (server) await new Promise((res) => server.close(res));
+ await pool.end();
+});
diff --git a/tests/valuation.test.js b/tests/valuation.test.js
new file mode 100644
index 0000000..e3d8ad6
--- /dev/null
+++ b/tests/valuation.test.js
@@ -0,0 +1,94 @@
+// 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 } = 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]`);
+ }
+});
+
+after(async () => {
+ await pool.end();
+});
diff --git a/views/asset.ejs b/views/asset.ejs
index 1b2b242..eb3873e 100644
--- a/views/asset.ejs
+++ b/views/asset.ejs
@@ -42,6 +42,17 @@
<p class="muted">No snapshot yet for this asset.</p>
<% } %>
+ <%
+ var _trendPoints = (data.comps || [])
+ .filter(function (c) { return c.transacted_at && c.gross != null; })
+ .map(function (c) { return { as_of: c.transacted_at, value: c.gross }; })
+ .sort(function (a, b) { return new Date(a.as_of) - new Date(b.as_of); });
+ %>
+ <section class="trend">
+ <h2>Price trend (comps)</h2>
+ <%- include('partials/sparkline', { points: _trendPoints, width: 480, height: 80 }) %>
+ </section>
+
<section class="comps">
<h2>Comparable transactions (<%= data.comps.length %>)</h2>
<% if (data.comps.length) { %>
diff --git a/views/index_detail.ejs b/views/index_detail.ejs
index 060b29a..02e3b66 100644
--- a/views/index_detail.ejs
+++ b/views/index_detail.ejs
@@ -11,6 +11,9 @@
<section>
<h2>Time series (<%= out.points.length %> points)</h2>
+ <div class="trend">
+ <%- include('partials/sparkline', { points: out.points, width: 480, height: 80 }) %>
+ </div>
<table>
<thead><tr><th>As of</th><th>Value</th><th>Δ%</th></tr></thead>
<tbody>
diff --git a/views/partials/sparkline.ejs b/views/partials/sparkline.ejs
new file mode 100644
index 0000000..ebfcce4
--- /dev/null
+++ b/views/partials/sparkline.ejs
@@ -0,0 +1,56 @@
+<%
+// sparkline.ejs — pure SSR inline-SVG line chart.
+//
+// locals: points (Array<{as_of, value}>), width?, height?, stroke?
+//
+// Renders <span class="muted">— insufficient data —</span> if fewer than 2
+// numeric points are available. Otherwise an inline SVG line + per-point dots,
+// padded 4px on each edge so the strokes don't get clipped.
+
+var _spark_points = (typeof points !== 'undefined' && Array.isArray(points)) ? points : [];
+var _spark_w = (typeof width !== 'undefined' && width) ? Number(width) : 320;
+var _spark_h = (typeof height !== 'undefined' && height) ? Number(height) : 60;
+var _spark_st = (typeof stroke !== 'undefined' && stroke) ? String(stroke) : 'currentColor';
+
+var _spark_clean = [];
+for (var _i = 0; _i < _spark_points.length; _i++) {
+ var _p = _spark_points[_i];
+ if (_p == null) continue;
+ var _v = _p.value;
+ if (_v == null) continue;
+ var _n = (typeof _v === 'string') ? parseFloat(_v) : _v;
+ if (!isFinite(_n)) continue;
+ _spark_clean.push({ as_of: _p.as_of, value: _n });
+}
+
+if (_spark_clean.length < 2) {
+%><span class="muted">— insufficient data —</span><%
+} else {
+ var _pad = 4;
+ var _innerW = Math.max(1, _spark_w - _pad * 2);
+ var _innerH = Math.max(1, _spark_h - _pad * 2);
+ var _minY = Infinity, _maxY = -Infinity;
+ for (var _j = 0; _j < _spark_clean.length; _j++) {
+ if (_spark_clean[_j].value < _minY) _minY = _spark_clean[_j].value;
+ if (_spark_clean[_j].value > _maxY) _maxY = _spark_clean[_j].value;
+ }
+ var _span = (_maxY - _minY) || 1;
+ var _step = _spark_clean.length > 1 ? _innerW / (_spark_clean.length - 1) : 0;
+ var _coords = _spark_clean.map(function (pt, idx) {
+ var x = _pad + _step * idx;
+ var y = _pad + _innerH - ((pt.value - _minY) / _span) * _innerH;
+ return { x: x, y: y };
+ });
+ var _path = _coords.map(function (c, idx) {
+ return (idx === 0 ? 'M' : 'L') + c.x.toFixed(2) + ' ' + c.y.toFixed(2);
+ }).join(' ');
+%><svg class="spark" xmlns="http://www.w3.org/2000/svg"
+ width="<%= _spark_w %>" height="<%= _spark_h %>"
+ viewBox="0 0 <%= _spark_w %> <%= _spark_h %>"
+ role="img" aria-label="sparkline">
+ <path d="<%= _path %>" fill="none" stroke="<%= _spark_st %>" stroke-width="1.5"
+ stroke-linecap="round" stroke-linejoin="round"/>
+ <% _coords.forEach(function (c) { %><circle cx="<%= c.x.toFixed(2) %>" cy="<%= c.y.toFixed(2) %>" r="2.2" fill="<%= _spark_st %>"/><% }); %>
+ </svg><%
+}
+%>
diff --git a/views/portfolio.ejs b/views/portfolio.ejs
new file mode 100644
index 0000000..9c7a6f9
--- /dev/null
+++ b/views/portfolio.ejs
@@ -0,0 +1,132 @@
+<%- include('partials/head') %>
+<main class="container">
+ <p class="crumbs"><a href="/">← Console</a></p>
+
+ <% if (!owner) { %>
+ <h1>Portfolio</h1>
+ <p class="muted">Enter the owner email to view a portfolio.</p>
+ <form action="/portfolio" method="get" class="owner-form">
+ <label for="owner">Owner email</label>
+ <input type="email" id="owner" name="owner" required placeholder="owner@example.com">
+ <button type="submit">View portfolio</button>
+ </form>
+ <% } else { %>
+ <h1>Portfolio — <%= owner %></h1>
+
+ <% if (flash === 'added') { %><p class="flash ok">Holding added.</p><% } %>
+ <% if (flash === 'removed') { %><p class="flash ok">Holding removed.</p><% } %>
+ <% if (flash === 'invalid_email') { %><p class="flash err">Invalid email.</p><% } %>
+ <% if (flash === 'invalid_asset_slug') { %><p class="flash err">Unknown asset slug.</p><% } %>
+
+ <section class="add-holding">
+ <h2>Add holding</h2>
+ <form action="/portfolio/add" method="post" class="holding-form">
+ <input type="hidden" name="owner_email" value="<%= owner %>">
+ <div class="row">
+ <label for="asset_slug">Asset</label>
+ <input list="asset-slugs" id="asset_slug" name="asset_slug" required placeholder="e.g. birkin-30-togo-gold-ghw-us">
+ <datalist id="asset-slugs">
+ <% assets.forEach(function(a){ %>
+ <option value="<%= a.slug %>"><%= a.brand_name %> <%= a.family_name %> <%= a.size || '' %> <%= a.material || '' %></option>
+ <% }); %>
+ </datalist>
+ </div>
+ <div class="row">
+ <label for="acquired_at">Acquired</label>
+ <input type="date" id="acquired_at" name="acquired_at">
+ </div>
+ <div class="row">
+ <label for="acquisition_basis">Basis (USD)</label>
+ <input type="number" id="acquisition_basis" name="acquisition_basis" min="0" step="0.01">
+ </div>
+ <div class="row">
+ <label for="notes">Notes</label>
+ <textarea id="notes" name="notes" rows="2"></textarea>
+ </div>
+ <button type="submit">Add holding</button>
+ </form>
+ </section>
+
+ <% if (!holdings.length) { %>
+ <p class="muted">No holdings yet.</p>
+ <% } else {
+ var sumBasis = 0, sumQ50 = 0, sumPL = 0, weightedDays = 0, weightDenom = 0;
+ holdings.forEach(function(h){
+ if (h.acquisition_basis != null) sumBasis += h.acquisition_basis;
+ if (h.q50 != null) sumQ50 += h.q50;
+ if (h.unrealized_pl != null) sumPL += h.unrealized_pl;
+ if (h.days_held != null && h.acquisition_basis != null) {
+ weightedDays += h.days_held * h.acquisition_basis;
+ weightDenom += h.acquisition_basis;
+ }
+ });
+ var avgDays = weightDenom ? Math.round(weightedDays / weightDenom) : null;
+ %>
+ <section class="holdings">
+ <h2>Holdings (<%= holdings.length %>)</h2>
+ <table>
+ <thead>
+ <tr>
+ <th scope="col">Brand</th>
+ <th scope="col">Family / Asset</th>
+ <th scope="col">Acquired</th>
+ <th scope="col">Basis</th>
+ <th scope="col">Current Q50</th>
+ <th scope="col">P&L $</th>
+ <th scope="col">P&L %</th>
+ <th scope="col">Days held</th>
+ <th scope="col">Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% holdings.forEach(function(h){ %>
+ <tr>
+ <td><%= h.brand %></td>
+ <td>
+ <a href="/asset/<%= h.asset_slug %>"><%= h.family %></a>
+ <div class="muted mono"><%= h.asset_slug %></div>
+ </td>
+ <td><%= fmtDate(h.acquired_at) %></td>
+ <td class="num">$<%= money(h.acquisition_basis) %></td>
+ <td class="num">$<%= money(h.q50) %></td>
+ <td class="num <%= plClass(h.unrealized_pl) %>">
+ <%= h.unrealized_pl == null ? '—' : (h.unrealized_pl >= 0 ? '+$' : '-$') + money(Math.abs(h.unrealized_pl)) %>
+ </td>
+ <td class="num <%= plClass(h.unrealized_pl) %>"><%= pct(h.pl_pct) %></td>
+ <td class="num"><%= h.days_held == null ? '—' : h.days_held %></td>
+ <td>
+ <form action="/portfolio/remove/<%= h.id %>" method="post" class="inline-remove">
+ <input type="hidden" name="owner_email" value="<%= owner %>">
+ <input type="hidden" name="_csrf_skip" value="1">
+ <button type="submit" class="link-btn" aria-label="Remove holding">Remove</button>
+ </form>
+ </td>
+ </tr>
+ <% }); %>
+ </tbody>
+ <tfoot>
+ <tr class="totals">
+ <th scope="row" colspan="3">Totals</th>
+ <td class="num">$<%= money(sumBasis) %></td>
+ <td class="num">$<%= money(sumQ50) %></td>
+ <td class="num <%= plClass(sumPL) %>">
+ <%= sumPL >= 0 ? '+$' : '-$' %><%= money(Math.abs(sumPL)) %>
+ </td>
+ <td class="num"><%= sumBasis ? pct(sumPL / sumBasis) : '—' %></td>
+ <td class="num"><%= avgDays == null ? '—' : avgDays %></td>
+ <td></td>
+ </tr>
+ </tfoot>
+ </table>
+ </section>
+ <% } %>
+ <% } %>
+</main>
+
+<%
+function money(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+function pct(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?(n*100).toFixed(1)+'%':'—'; }
+function fmtDate(v){ if(!v) return '—'; try { return new Date(v).toISOString().slice(0,10); } catch(e){ return String(v); } }
+function plClass(v){ if(v==null) return ''; return v >= 0 ? 'pl-pos' : 'pl-neg'; }
+%>
+<%- include('partials/foot') %>
← 0877b89 catalog breadth: 14 more assets + 3 indices (Max It batch)
·
back to Lifestyle Asset Intel
·
yolo tick #1: enforce transactions natural-key + seed idempo 1d24570 →