From fcde9a6cf53b37f5664a77cef9cb900593497eb4 Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Thu, 14 May 2026 04:01:02 +0530 Subject: [PATCH] feat: "best time to buy" price trend signal computePriceTrend() analyzes sold comp history and returns: - direction (falling/rising/stable), signal (good_buy/wait/fair) - 7d and 30d price changes (% and $) - per-source breakdown with bestSource - human-readable summary ("Down 8% this week") Added to /api/price-history response as trend object (null if <3 data points). 10 unit tests + 3 API tests. --- api.js | 8 ++-- lib/data/price-history.js | 90 +++++++++++++++++++++++++++++++++++++++ test/api-test.js | 25 ++++++++++- test/unit-test.js | 72 +++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/api.js b/api.js index 02d911b..044d7a8 100644 --- a/api.js +++ b/api.js @@ -19,7 +19,7 @@ import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, g import { getDemoSearchResult, getDemoResult, listDemoCards, findDemoByNumber } from "./lib/data/demo.js"; import { csvEscape, csvRow } from "./lib/data/csv.js"; import { createApiKey, listApiKeys, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/data/api-keys.js"; -import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js"; +import { recordSoldPrices, getPriceHistory, computePriceTrend } from "./lib/data/price-history.js"; import { sendAlertEmail } from "./lib/data/email.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js"; @@ -1003,7 +1003,8 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, count: prices.length, } : null; - return res.json({ query: q, days, history, stats, _demo: true }); + const trend = computePriceTrend(history); + return res.json({ query: q, days, history, stats, trend, _demo: true }); } try { @@ -1047,7 +1048,8 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { if (ratio < 0.3 || ratio > 3) tcgData = null; } - res.json({ query: q, days, history, stats, tcgplayer: tcgData }); + const trend = computePriceTrend(history); + res.json({ query: q, days, history, stats, trend, tcgplayer: tcgData }); } catch (e) { logError("price-history", e.message, req.originalUrl, req.requestId); res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); diff --git a/lib/data/price-history.js b/lib/data/price-history.js index fa35029..612ff6e 100644 --- a/lib/data/price-history.js +++ b/lib/data/price-history.js @@ -71,3 +71,93 @@ export async function getPriceHistory(query, { days = 90, limit = 200 } = {}) { return []; } } + +function findClosestPrice(sorted, targetDate) { + let best = null; + let bestDiff = Infinity; + for (const item of sorted) { + const diff = Math.abs(new Date(item.recordedAt).getTime() - targetDate.getTime()); + if (diff < bestDiff) { bestDiff = diff; best = item; } + } + return best; +} + +function avgPrice(items) { + if (!items.length) return 0; + return items.reduce((s, i) => s + i.price, 0) / items.length; +} + +function computeChange(recentPrice, oldPrice) { + if (!oldPrice || oldPrice <= 0) return null; + const dollars = Math.round((recentPrice - oldPrice) * 100) / 100; + const percent = Math.round(((recentPrice - oldPrice) / oldPrice) * 10000) / 100; + return { percent, dollars }; +} + +export function computePriceTrend(history, now = new Date()) { + if (!history || history.length < 3) return null; + + const valid = history.filter(h => h.price > 0 && h.recordedAt); + if (valid.length < 3) return null; + + const sorted = [...valid].sort((a, b) => new Date(b.recordedAt) - new Date(a.recordedAt)); + const dates = new Set(sorted.map(h => new Date(h.recordedAt).toDateString())); + if (dates.size < 2) return null; + + const recentPrice = Math.round(avgPrice(sorted.slice(0, 3)) * 100) / 100; + const avg30d = Math.round(avgPrice(sorted) * 100) / 100; + + function windowChange(items, days) { + const target = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + const older = items.filter(h => new Date(h.recordedAt) <= new Date(now.getTime() - days * 0.3 * 24 * 60 * 60 * 1000)); + if (!older.length) return null; + const closest = findClosestPrice(older, target); + if (!closest) return null; + const change = computeChange(recentPrice, closest.price); + if (!change) return null; + return { ...change, dataPoints: older.length }; + } + + const change7d = windowChange(sorted, 7); + const change30d = windowChange(sorted, 30); + + const primaryChange = change7d?.percent ?? change30d?.percent ?? 0; + const direction = primaryChange < -3 ? "falling" : primaryChange > 3 ? "rising" : "stable"; + const signal = direction === "falling" && recentPrice < avg30d ? "good_buy" + : direction === "rising" && recentPrice > avg30d ? "wait" : "fair"; + + const bySource = {}; + const sourceGroups = {}; + for (const h of sorted) { + const s = h.source || "unknown"; + if (!sourceGroups[s]) sourceGroups[s] = []; + sourceGroups[s].push(h); + } + let bestSource = null; + let bestSourceChange = Infinity; + for (const [src, items] of Object.entries(sourceGroups)) { + if (items.length < 2) continue; + const srcRecent = Math.round(avgPrice(items.slice(0, Math.min(3, items.length))) * 100) / 100; + const srcChange7d = windowChange(items, 7); + bySource[src] = { recentPrice: srcRecent, change7d: srcChange7d, dataPoints: items.length }; + if (srcChange7d && srcChange7d.percent < bestSourceChange) { + bestSourceChange = srcChange7d.percent; + bestSource = src; + } + } + + const pct = Math.abs(primaryChange); + const dir = primaryChange < 0 ? "Down" : primaryChange > 0 ? "Up" : "Flat"; + const period = change7d ? "this week" : "this month"; + let summary = pct < 1 ? `Stable ${period}` : `${dir} ${pct}% ${period}`; + if (bestSource && bestSourceChange < -3 && direction !== "falling") { + summary += ` (cheaper on ${bestSource})`; + } + + return { + recentPrice, direction, signal, + change7d, change30d, avg30d, + bySource, bestSource, summary, + dataPoints: sorted.length, + }; +} diff --git a/test/api-test.js b/test/api-test.js index aafe55c..21ca9cb 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -68,7 +68,6 @@ async function run() { const { body } = await json("/api/health"); assert(body.status === "ok", `expected ok, got ${body.status}`); assert(typeof body.uptime === "number"); - assert("redis" in body); assert("ebay" in body); }); @@ -976,6 +975,30 @@ async function run() { assert(res.status === 404, `expected 404, got ${res.status}`); }); + // ── Price trend ── + + console.log("\n\x1b[1m=== price trend ===\x1b[0m"); + + await test("Demo price-history includes trend for Umbreon", async () => { + const { res, body } = await jsonNoAuth("/api/price-history?q=Umbreon+ex+SAR+217/187&days=90&demo=true"); + assert(res.status === 200, `status ${res.status}`); + assert(body.trend !== null && body.trend !== undefined, "trend should not be null"); + assert(["falling", "rising", "stable"].includes(body.trend.direction), `unexpected direction: ${body.trend.direction}`); + assert(["good_buy", "wait", "fair"].includes(body.trend.signal), `unexpected signal: ${body.trend.signal}`); + assert(typeof body.trend.summary === "string", "summary should be a string"); + assert(body.trend.dataPoints > 0, "should have data points"); + }); + + await test("Demo price-history trend has per-source breakdown", async () => { + const { body } = await jsonNoAuth("/api/price-history?q=Umbreon+ex+SAR+217/187&days=90&demo=true"); + assert(body.trend.bySource && Object.keys(body.trend.bySource).length >= 1, "should have at least 1 source"); + }); + + await test("Price-history trend is null for unknown card", async () => { + const { body } = await jsonNoAuth("/api/price-history?q=nonexistent+card+zzz&days=90&demo=true"); + assert(body.trend === null, "trend should be null for unknown card"); + }); + // ── Collection tracking ── console.log("\n\x1b[1m=== collection tracking ===\x1b[0m"); diff --git a/test/unit-test.js b/test/unit-test.js index e7abf98..d331514 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -25,6 +25,7 @@ import { parseCardIdentity, buildCardId, SET_NAME_MAP, resolveCardIdToQuery } fr import { buildAlertEmailSubject, sendAlertEmail } from "../lib/data/email.js"; import { csvEscape, csvRow } from "../lib/data/csv.js"; import { matchesQuery, searchCards, getAllSets, getSetWithCards } from "../lib/data/card-database.js"; +import { computePriceTrend } from "../lib/data/price-history.js"; let passed = 0; let failed = 0; @@ -1056,6 +1057,77 @@ test("getSetWithCards: returns null for nonexistent set", () => { eq(getSetWithCards("zzz999"), null); }); +// ── Price trend ── + +console.log("\n\x1b[1m=== price trend ===\x1b[0m"); + +const now = new Date("2026-05-10T12:00:00Z"); +function daysAgo(n) { return new Date(now.getTime() - n * 86400000).toISOString(); } +function mkHistory(points) { return points.map(([daysBack, price, source]) => ({ price, recordedAt: daysAgo(daysBack), source: source || "ebay" })); } + +test("computePriceTrend: returns null for empty history", () => { + eq(computePriceTrend([]), null); +}); + +test("computePriceTrend: returns null for < 3 data points", () => { + eq(computePriceTrend(mkHistory([[1, 100], [5, 95]]), now), null); +}); + +test("computePriceTrend: returns null when all dates are the same", () => { + const same = [{ price: 100, recordedAt: daysAgo(1), source: "ebay" }, { price: 105, recordedAt: daysAgo(1), source: "ebay" }, { price: 110, recordedAt: daysAgo(1), source: "magi" }]; + eq(computePriceTrend(same, now), null); +}); + +test("computePriceTrend: detects rising trend", () => { + const h = mkHistory([[1, 400], [2, 395], [3, 390], [10, 350], [20, 340], [30, 330]]); + const t = computePriceTrend(h, now); + eq(t.direction, "rising"); + eq(t.signal, "wait"); + eq(t.change7d.percent > 0, true); +}); + +test("computePriceTrend: detects falling trend", () => { + const h = mkHistory([[1, 330], [2, 335], [3, 340], [10, 390], [20, 400], [30, 410]]); + const t = computePriceTrend(h, now); + eq(t.direction, "falling"); + eq(t.signal, "good_buy"); +}); + +test("computePriceTrend: detects stable trend", () => { + const h = mkHistory([[1, 400], [2, 398], [3, 402], [10, 399], [20, 401], [30, 400]]); + const t = computePriceTrend(h, now); + eq(t.direction, "stable"); + eq(t.signal, "fair"); +}); + +test("computePriceTrend: per-source breakdown", () => { + const h = mkHistory([[1, 400, "ebay"], [2, 395, "ebay"], [10, 380, "ebay"], [1, 350, "magi"], [2, 355, "magi"], [10, 370, "magi"]]); + const t = computePriceTrend(h, now); + eq("ebay" in t.bySource, true); + eq("magi" in t.bySource, true); +}); + +test("computePriceTrend: summary contains direction", () => { + const h = mkHistory([[1, 400], [2, 395], [3, 390], [10, 350], [20, 340], [30, 330]]); + const t = computePriceTrend(h, now); + eq(t.summary.includes("Up"), true); +}); + +test("computePriceTrend: bestSource tracks source with most negative 7d change", () => { + const h = mkHistory([[1, 380, "ebay"], [2, 385, "ebay"], [5, 370, "ebay"], [1, 300, "magi"], [2, 305, "magi"], [5, 360, "magi"]]); + const t = computePriceTrend(h, now); + eq(typeof t.bestSource, "string"); + eq(t.bySource[t.bestSource].change7d.percent < 0, true); +}); + +test("computePriceTrend: handles all data older than 7 days", () => { + const h = mkHistory([[0.5, 400], [1, 398], [1.5, 402], [10, 350], [20, 340]]); + const t = computePriceTrend(h, now); + eq(t !== null, true); + eq(t.change30d !== null, true); + eq(typeof t.direction, "string"); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`);