[object Object]

← 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

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="/">&larr; 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 &mdash; <%= 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&amp;L $</th>
+              <th scope="col">P&amp;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 ? '&mdash;' : (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 ? '&mdash;' : 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) : '&mdash;' %></td>
+              <td class="num"><%= avgDays == null ? '&mdash;' : avgDays %></td>
+              <td></td>
+            </tr>
+          </tfoot>
+        </table>
+      </section>
+    <% } %>
+  <% } %>
+</main>
+
+<%
+function money(v){ if(v==null||v==='') return '&mdash;'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'&mdash;'; }
+function pct(v){ if(v==null||v==='') return '&mdash;'; var n=parseFloat(v); return isFinite(n)?(n*100).toFixed(1)+'%':'&mdash;'; }
+function fmtDate(v){ if(!v) return '&mdash;'; 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 →