feat(hype-cycle): add /market-signals endpoint (data-driven per-tech signal score + drivers + recommendation)

This commit is contained in:
Rene Fichtmueller 2026-06-04 20:47:30 +00:00
parent b5a961c3bd
commit b720afc92c

View File

@ -198,6 +198,74 @@ hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
}); });
// GET /api/hype-cycle/:tech — Specific technology detail (must be last!) // GET /api/hype-cycle/:tech — Specific technology detail (must be last!)
// GET /api/hype-cycle/market-signals — data-driven per-technology market signal score + drivers + recommendation
hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => {
try {
const yearParam = q("year", _req);
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
const overridesMap = await getDataDrivenOverrides();
const speedMetrics = await getSpeedClassMetrics();
const metricsBySpeed = new Map(speedMetrics.map((m) => [m.speedGbps, m]));
const maxSku = Math.max(1, ...speedMetrics.map((m) => m.skuCount));
const allTechs = [...TECH_GENERATIONS, ...SPECIAL_TECHS];
const technologies = allTechs.map((tech) => {
const r = computeHypeCycle(tech, year, overridesMap.get(tech.speedGbps));
const sm = metricsBySpeed.get(tech.speedGbps);
const adoption = Math.max(0, Math.round(r.adoptionPct));
const composite = Math.max(0, Math.round(r.compositeScore));
const depth = sm ? Math.round(100 * Math.min(1, sm.skuCount / maxSku)) : 0;
const marketSignalScore = Math.max(0, Math.min(100, Math.round(0.4 * adoption + 0.3 * composite + 0.3 * depth)));
const drivers: string[] = [];
drivers.push(adoption + "% modeled adoption");
if (sm && sm.skuCount) drivers.push(sm.skuCount + " SKUs / " + sm.vendorCount + " vendors");
if (sm && sm.avgPrice) drivers.push("avg $" + Math.round(sm.avgPrice));
drivers.push("phase: " + r.phaseLabel);
const phase = String(r.phaseLabel || "");
let recommendation = { label: "Monitor", detail: "Insufficient market momentum — keep watching.", color: "#94a3b8" };
if (/Plateau/i.test(phase)) recommendation = { label: "Commodity — negotiate", detail: "Mature, broad supply (" + (sm ? sm.vendorCount : 0) + " vendors). Push for price.", color: "#0ea5e9" };
else if (/Slope/i.test(phase)) recommendation = { label: "Mainstream — buy", detail: "Mainstreaming with falling prices and rising supply.", color: "#16a34a" };
else if (/Trough/i.test(phase)) recommendation = { label: "Maturing — opportunity", detail: "Past the hype; early mainstream pricing forming.", color: "#ca8a04" };
else if (/Peak/i.test(phase)) recommendation = { label: "Hype peak — caution", detail: "Elevated expectations; supply and price still volatile.", color: "#f97316" };
else if (/Innovation|Trigger/i.test(phase)) recommendation = { label: "Emerging — pilot", detail: "Early window; limited supply, premium pricing.", color: "#ca8a04" };
return {
technology: tech.name,
phase: r.phaseLabel,
marketSignalScore,
adoptionPct: adoption,
compositeScore: composite,
skuCount: sm ? sm.skuCount : 0,
vendorCount: sm ? sm.vendorCount : 0,
avgPrice: sm && sm.avgPrice ? Math.round(sm.avgPrice * 100) / 100 : null,
drivers,
recommendation,
};
});
technologies.sort((a, b) => b.marketSignalScore - a.marketSignalScore);
const totalSkus = speedMetrics.reduce((acc, m) => acc + m.skuCount, 0);
const totalVendors = speedMetrics.length ? Math.max(...speedMetrics.map((m) => m.vendorCount)) : 0;
const avgSignal = technologies.length ? Math.round(technologies.reduce((acc, t) => acc + t.marketSignalScore, 0) / technologies.length) : 0;
const globalContext = {
totalSkus,
totalVendors,
avgMarketSignal: avgSignal,
strongest: technologies.length ? technologies[0].technology : null,
strongestScore: technologies.length ? technologies[0].marketSignalScore : null,
generatedAt: new Date().toISOString(),
};
res.json({ success: true, year, technologies, globalContext });
} catch (err) {
console.error("market-signals error:", err);
res.status(500).json({ success: false, error: "Failed to compute market signals" });
}
});
hypeCycleRouter.get("/:tech", (req: Request, res: Response) => { hypeCycleRouter.get("/:tech", (req: Request, res: Response) => {
const techQuery = String(req.params.tech); const techQuery = String(req.params.tech);
const yearParam = q("year", req); const yearParam = q("year", req);