G) SKU Bulk Pricer — POST /api/bulk-price (bis 100 Part Numbers), Preistabelle je Vendor, CSV-Export, Not-Found-Liste H) Side-by-side Comparison — Checkboxes in TX-Tabelle, Floating Comparison-Tray (max 4 SKUs), Modal mit Specs + Best-Price 7d nebeneinander I) Vendor Reliability Score — GET /api/vendors/reliability, Freshness(40) + Frequency(30) + Coverage(30) = 0–100, Progress-Bar-Badge auf Vendor-Cards J) Price Heat Map — GET /api/price-matrix?ids=, Row-normalisierte Farbmatrix SKU×Vendor (grün=günstig/rot=teuer), Sticky SKU-Spalte, Best-Price-Spalte K) Watchlist — localStorage-basiert (⭐/☆ in TX-Tabelle), Floating Drawer, live Preis-Update via Einzelabruf L) PDF / Print Report — window.print() + dediziertes @media print CSS (blendet UI-Chrome aus, behält Overview-Content) M) Global Search Overlay — Cmd+K / Ctrl+K, durchsucht Transceivers + KB + News + Documents gleichzeitig, clickbare Direktlinks N) Saved Filter Presets — localStorage tip_presets, Dropdown + 💾-Button in TX-Filterzeile, Save/Load/Delete O) Price Forecast — GET /api/price-forecast/:id (lineare Regression 90d → 30d Forecast), gestricheltes Overlay auf Price-History-Chart, Trend-Label (rising/stable/declining) P) Technology Radar — SVG Bull's-Eye (Adopt/Trial/Assess/Hold), Hype-Cycle-Phasen → Ringe gemappt, Bubbles mit Market-Signal-Score, Quadrant-Labels, interaktive Tooltips
112 lines
3.8 KiB
TypeScript
112 lines
3.8 KiB
TypeScript
/**
|
|
* Price Forecast — Linear Regression
|
|
*
|
|
* Routes:
|
|
* GET /api/price-forecast/:id — 30-day forecast for a transceiver
|
|
*/
|
|
import { Router, Request, Response } from "express";
|
|
import { pool } from "../db/client";
|
|
|
|
export const priceForecastRouter = Router();
|
|
|
|
const MIN_PRICE = 0.01;
|
|
|
|
function linearRegression(xs: number[], ys: number[]): { slope: number; intercept: number; rSquared: number } {
|
|
const n = xs.length;
|
|
if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, rSquared: 0 };
|
|
|
|
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0);
|
|
const sumX2 = xs.reduce((acc, x) => acc + x * x, 0);
|
|
|
|
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
|
const intercept = (sumY - slope * sumX) / n;
|
|
|
|
const yMean = sumY / n;
|
|
const ssTot = ys.reduce((acc, y) => acc + (y - yMean) ** 2, 0);
|
|
const ssRes = xs.reduce((acc, x, i) => acc + (ys[i] - (slope * x + intercept)) ** 2, 0);
|
|
const rSquared = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
|
|
|
|
return { slope, intercept, rSquared };
|
|
}
|
|
|
|
function addDays(base: Date, n: number): string {
|
|
const d = new Date(base);
|
|
d.setUTCDate(d.getUTCDate() + n);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
// ─── GET /api/price-forecast/:id ─────────────────────────────────────────────
|
|
priceForecastRouter.get("/:id", async (req: Request, res: Response) => {
|
|
try {
|
|
const id = parseInt(String(req.params.id), 10);
|
|
if (!Number.isFinite(id) || id <= 0) {
|
|
res.status(400).json({ success: false, error: "Invalid transceiver id" });
|
|
return;
|
|
}
|
|
|
|
const histResult = await pool.query<{ day: Date; avg_price: string }>(
|
|
`SELECT
|
|
DATE(time) AS day,
|
|
AVG(price) AS avg_price
|
|
FROM price_observations
|
|
WHERE transceiver_id = $1
|
|
AND time > NOW() - INTERVAL '90 days'
|
|
GROUP BY DATE(time)
|
|
ORDER BY day`,
|
|
[id]
|
|
);
|
|
|
|
if (histResult.rows.length === 0) {
|
|
res.status(404).json({ success: false, error: "No price history found for this transceiver" });
|
|
return;
|
|
}
|
|
|
|
const history = histResult.rows.map((r) => ({
|
|
date: r.day.toISOString().slice(0, 10),
|
|
avg_price: parseFloat(r.avg_price),
|
|
}));
|
|
|
|
// Use day-0 offset as x axis so numbers stay small
|
|
const epoch0 = new Date(history[0].date + "T00:00:00Z").getTime();
|
|
const xs = history.map((h) => (new Date(h.date + "T00:00:00Z").getTime() - epoch0) / 86_400_000);
|
|
const ys = history.map((h) => h.avg_price);
|
|
|
|
const { slope, intercept, rSquared } = linearRegression(xs, ys);
|
|
|
|
const lastDate = new Date(history[history.length - 1].date + "T00:00:00Z");
|
|
const lastX = xs[xs.length - 1];
|
|
const forecast = Array.from({ length: 30 }, (_, i) => {
|
|
const dayOffset = lastX + i + 1;
|
|
const rawPrice = slope * dayOffset + intercept;
|
|
const predictedPrice = Math.max(MIN_PRICE, Math.round(rawPrice * 100) / 100);
|
|
return {
|
|
date: addDays(lastDate, i + 1),
|
|
predicted_price: predictedPrice,
|
|
is_forecast: true,
|
|
};
|
|
});
|
|
|
|
const trend =
|
|
slope > 0.05 ? "rising" :
|
|
slope < -0.05 ? "declining" : "stable";
|
|
|
|
const forecast30dPrice = forecast[29].predicted_price;
|
|
|
|
res.json({
|
|
success: true,
|
|
transceiver_id: id,
|
|
history,
|
|
forecast,
|
|
trend,
|
|
slope_per_day: Math.round(slope * 10_000) / 10_000,
|
|
r_squared: Math.round(rSquared * 100) / 100,
|
|
forecast_30d_price: forecast30dPrice,
|
|
});
|
|
} catch (err) {
|
|
console.error("GET /api/price-forecast/:id error:", err);
|
|
res.status(500).json({ success: false, error: String(err) });
|
|
}
|
|
});
|