Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });
Expand Down
90 changes: 90 additions & 0 deletions lib/data/price-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
25 changes: 24 additions & 1 deletion test/api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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");
Expand Down
72 changes: 72 additions & 0 deletions test/unit-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`);
Expand Down
Loading