/** * 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) }); } });