← back to Lifestyle Asset Intel
yolo tick #26: /api/index list endpoint with sort + latest-point summary
a2a7ff07a545adf40518ed18504bef5546a792e2 · 2026-05-13 22:51:33 -0700 · Steve Abrams
Returns one row per benchmark index: identity (slug/name/definition/
methodology_version), point_count, first_as_of, latest_as_of,
latest_value, latest_change_pct. Single SQL pass with two lateral
subqueries so it stays cheap as indices and index_points grow.
Sort modes: slug (default), name, value-desc, value-asc, change-desc,
change-asc, points-desc, recent-desc. Unknown sort values fall back to
default ordering instead of erroring.
OpenAPI 3.1 spec updated. Three new smoke tests cover the list shape,
value-desc ordering with nulls-last, and bogus-sort fallback.
58/58 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M lib/openapi.jsM lib/valuation.jsM routes/api.jsM tests/smoke.test.js
Diff
commit a2a7ff07a545adf40518ed18504bef5546a792e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 22:51:33 2026 -0700
yolo tick #26: /api/index list endpoint with sort + latest-point summary
Returns one row per benchmark index: identity (slug/name/definition/
methodology_version), point_count, first_as_of, latest_as_of,
latest_value, latest_change_pct. Single SQL pass with two lateral
subqueries so it stays cheap as indices and index_points grow.
Sort modes: slug (default), name, value-desc, value-asc, change-desc,
change-asc, points-desc, recent-desc. Unknown sort values fall back to
default ordering instead of erroring.
OpenAPI 3.1 spec updated. Three new smoke tests cover the list shape,
value-desc ordering with nulls-last, and bogus-sort fallback.
58/58 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/openapi.js | 40 ++++++++++++++++++++++
lib/valuation.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
routes/api.js | 10 +++++-
tests/smoke.test.js | 41 +++++++++++++++++++++-
4 files changed, 187 insertions(+), 3 deletions(-)
diff --git a/lib/openapi.js b/lib/openapi.js
index 8d6a310..3ca66f8 100644
--- a/lib/openapi.js
+++ b/lib/openapi.js
@@ -397,6 +397,46 @@ function buildSpec({ version, gitSha }) {
}
}
},
+ '/api/index': {
+ get: {
+ summary: 'List every benchmark index with latest point summary',
+ description:
+ 'Returns one row per benchmark index: slug, name, definition, ' +
+ 'methodology_version, point_count, first_as_of, plus the latest ' +
+ 'point (latest_as_of, latest_value, latest_change_pct). Default ' +
+ 'order is by slug. Pass ?sort= to switch — supported modes: ' +
+ 'slug, name, value-desc, value-asc, change-desc, change-asc, ' +
+ 'points-desc, recent-desc. Unknown sort values fall back to ' +
+ 'default ordering.',
+ tags: ['indices'],
+ parameters: [{
+ name: 'sort', in: 'query', required: false,
+ schema: { type: 'string' }, example: 'value-desc'
+ }],
+ responses: {
+ 200: { description: 'OK', content: { 'application/json': { schema: {
+ type: 'object',
+ properties: {
+ count: { type: 'integer' },
+ sort: { type: 'string', nullable: true },
+ indices: { type: 'array', items: {
+ type: 'object',
+ properties: {
+ slug: { type: 'string' }, name: { type: 'string' },
+ definition: { type: 'object', additionalProperties: true },
+ methodology_version: { type: 'string' },
+ point_count: { type: 'integer' },
+ first_as_of: { type: 'string', format: 'date', nullable: true },
+ latest_as_of: { type: 'string', format: 'date', nullable: true },
+ latest_value: { type: 'number', nullable: true },
+ latest_change_pct: { type: 'number', nullable: true }
+ }
+ } }
+ }
+ } } } }
+ }
+ }
+ },
'/api/index/{slug}': {
get: {
summary: 'Benchmark index time series',
diff --git a/lib/valuation.js b/lib/valuation.js
index 3fff123..c584ce0 100644
--- a/lib/valuation.js
+++ b/lib/valuation.js
@@ -665,6 +665,103 @@ async function getIndex(slug) {
};
}
+// listIndices() — index of every benchmark index with a one-row
+// summary: identity (slug/name/methodology_version/definition), point
+// counts, and the latest point's value + as_of + change_pct.
+// Single SQL pass with a lateral subquery for the latest point, so this
+// stays cheap as the indices table grows.
+//
+// opts.sort (string) — see INDEX_SORTS. Default ordering is by slug.
+async function listIndices(opts) {
+ const sort = opts && typeof opts.sort === 'string' ? opts.sort.trim() : '';
+ const rows = await many(
+ `SELECT i.slug, i.name, i.definition, i.methodology_version,
+ COALESCE(stats.point_count, 0)::int AS point_count,
+ stats.first_as_of, latest.as_of AS latest_as_of,
+ latest.value AS latest_value,
+ latest.change_pct AS latest_change_pct
+ FROM indices i
+ LEFT JOIN LATERAL (
+ SELECT COUNT(*)::int AS point_count,
+ MIN(as_of) AS first_as_of
+ FROM index_points ip
+ WHERE ip.index_id = i.id
+ ) stats ON true
+ LEFT JOIN LATERAL (
+ SELECT as_of, value, change_pct
+ FROM index_points ip
+ WHERE ip.index_id = i.id
+ ORDER BY as_of DESC
+ LIMIT 1
+ ) latest ON true
+ ORDER BY i.slug`
+ );
+ const enriched = rows.map((r) => ({
+ slug: r.slug,
+ name: r.name,
+ definition: r.definition,
+ methodology_version: r.methodology_version,
+ point_count: r.point_count,
+ first_as_of: r.first_as_of,
+ latest_as_of: r.latest_as_of,
+ latest_value: r.latest_value != null ? +parseFloat(r.latest_value).toFixed(2) : null,
+ latest_change_pct: r.latest_change_pct != null ? +parseFloat(r.latest_change_pct).toFixed(4) : null
+ }));
+ return applyIndexSort(enriched, sort);
+}
+
+// Supported sort modes for /api/index. Default (slug ASC) matches the
+// SQL ORDER BY above; other modes are applied in JS post-aggregation so
+// computed columns sort against what callers actually see.
+const INDEX_SORTS = new Set([
+ 'default', 'slug', 'name',
+ 'value-desc', 'value-asc',
+ 'change-desc', 'change-asc',
+ 'points-desc', 'recent-desc'
+]);
+function applyIndexSort(rows, sort) {
+ if (!sort || !INDEX_SORTS.has(sort) || sort === 'default') return rows;
+ const sorted = rows.slice();
+ const cmpNullsLast = (av, bv, desc) => {
+ const an = av == null, bn = bv == null;
+ if (an && bn) return 0;
+ if (an) return 1;
+ if (bn) return -1;
+ return desc ? (bv - av) : (av - bv);
+ };
+ switch (sort) {
+ case 'slug':
+ sorted.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
+ break;
+ case 'name':
+ sorted.sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ break;
+ case 'value-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.latest_value, b.latest_value, true));
+ break;
+ case 'value-asc':
+ sorted.sort((a, b) => cmpNullsLast(a.latest_value, b.latest_value, false));
+ break;
+ case 'change-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.latest_change_pct, b.latest_change_pct, true));
+ break;
+ case 'change-asc':
+ sorted.sort((a, b) => cmpNullsLast(a.latest_change_pct, b.latest_change_pct, false));
+ break;
+ case 'points-desc':
+ sorted.sort((a, b) => cmpNullsLast(a.point_count, b.point_count, true));
+ break;
+ case 'recent-desc':
+ sorted.sort((a, b) => {
+ const ad = a.latest_as_of ? new Date(a.latest_as_of).getTime() : null;
+ const bd = b.latest_as_of ? new Date(b.latest_as_of).getTime() : null;
+ return cmpNullsLast(ad, bd, true);
+ });
+ break;
+ }
+ return sorted;
+}
+
function numberOrNull(v) {
if (v === null || v === undefined) return null;
const n = typeof v === 'string' ? parseFloat(v) : v;
@@ -693,7 +790,7 @@ function quantile(sorted, q) {
}
module.exports = {
- valueAsset, listAssets, getIndex, getFamily, listFamilies,
+ valueAsset, listAssets, getIndex, getFamily, listFamilies, listIndices,
computeBreakdown, computeLiquidity, computeExpectedDts, quantile,
METHODOLOGY_VERSION
};
diff --git a/routes/api.js b/routes/api.js
index 2f08be6..7fa5f61 100644
--- a/routes/api.js
+++ b/routes/api.js
@@ -4,7 +4,7 @@ const path = require('path');
const { execSync } = require('child_process');
const { pool } = require('../lib/db');
const {
- valueAsset, listAssets, getIndex, getFamily, listFamilies, METHODOLOGY_VERSION
+ valueAsset, listAssets, getIndex, listIndices, getFamily, listFamilies, METHODOLOGY_VERSION
} = require('../lib/valuation');
const {
isValidEmail,
@@ -164,6 +164,14 @@ router.get('/family/:slug', setCache(PUB_300), async (req, res, next) => {
} catch (e) { next(e); }
});
+router.get('/index', setCache(PUB_300), async (req, res, next) => {
+ try {
+ const sort = typeof req.query.sort === 'string' ? req.query.sort : '';
+ const indices = await listIndices({ sort });
+ res.json({ count: indices.length, sort: sort || null, indices });
+ } catch (e) { next(e); }
+});
+
router.get('/index/:slug', setCache(PUB_300), async (req, res, next) => {
try {
const out = await getIndex(req.params.slug);
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index e33664c..395039b 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -143,7 +143,7 @@ test('GET /api/openapi.json is valid OpenAPI 3.1 with all current routes', async
const documented = Object.keys(r.json.paths);
for (const p of [
'/api/health', '/api/version', '/api/sources', '/api/assets',
- '/api/valuation/{slug}', '/api/index/{slug}',
+ '/api/valuation/{slug}', '/api/index', '/api/index/{slug}',
'/api/portfolio', '/api/portfolio/{id}', '/api/audit/recent'
]) {
assert.ok(documented.includes(p), `OpenAPI missing path ${p}`);
@@ -304,6 +304,45 @@ test('GET /api/index/birkin-30-togo-neutral returns time series', async () => {
assert.ok(r.json.points.length >= 4);
});
+test('GET /api/index lists every benchmark index with latest-point summary', async () => {
+ const r = await get('/api/index');
+ assert.equal(r.status, 200);
+ assert.ok(typeof r.json.count === 'number');
+ assert.ok(Array.isArray(r.json.indices));
+ assert.ok(r.json.indices.length >= 4, 'at least the four seeded indices');
+ const slugs = r.json.indices.map((i) => i.slug);
+ assert.ok(slugs.includes('birkin-30-togo-neutral'));
+ assert.ok(slugs.includes('kelly-sellier-premium'));
+ const row = r.json.indices.find((i) => i.slug === 'birkin-30-togo-neutral');
+ assert.ok(row.methodology_version);
+ assert.ok(row.point_count >= 4);
+ assert.ok(typeof row.latest_value === 'number');
+ assert.ok(row.latest_as_of, 'latest_as_of is present');
+});
+
+test('GET /api/index?sort=value-desc orders by latest_value descending (nulls last)', async () => {
+ const r = await get('/api/index?sort=value-desc');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.sort, 'value-desc');
+ const vals = r.json.indices.map((i) => i.latest_value);
+ let sawNull = false;
+ for (let i = 0; i < vals.length; i++) {
+ if (vals[i] == null) { sawNull = true; continue; }
+ assert.ok(!sawNull, `non-null latest_value at idx ${i} appeared after a null`);
+ if (i > 0 && vals[i - 1] != null) {
+ assert.ok(vals[i] <= vals[i - 1],
+ `value-desc broken at idx ${i}: ${vals[i - 1]} < ${vals[i]}`);
+ }
+ }
+});
+
+test('GET /api/index?sort=bogus falls back to default ordering (no error)', async () => {
+ const r = await get('/api/index?sort=not-a-real-sort');
+ assert.equal(r.status, 200);
+ assert.ok(Array.isArray(r.json.indices));
+ assert.ok(r.json.indices.length >= 4);
+});
+
test('GET / renders the console with sort and density controls', async () => {
const r = await get('/');
assert.equal(r.status, 200);
← 41093f5 yolo tick #25: family-detail grid gets sort + density + sear
·
back to Lifestyle Asset Intel
·
yolo tick #27: /api/sources ?sort= (weight/tier/name/slug/ki c3d5177 →