feat: add BELO peak hour analysis chart

- New backend endpoint GET /quotes/:type/peak-hours
- Groups daily price data by hour to find peak frequency
- Bar chart showing how often each hour is the daily peak
- Only displayed for BELO currency in analysis page
This commit is contained in:
Jose Selesan
2026-08-28 12:05:43 -03:00
parent 2fe8198c41
commit 0998d64cbb
6 changed files with 339 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import type { Context } from "hono";
import type { QuoteType } from "../../../generated/prisma/client";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
import { parseLocalDate, startOfNextLocalDay } from "../../../lib/utils";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
export async function getPeakHours(c: Context) {
const rawType = c.req.param("type")?.toUpperCase();
if (!VALID_TYPES.includes(rawType as (typeof VALID_TYPES)[number])) {
return c.json(
{
error: `Invalid quote type. Must be one of: ${VALID_TYPES.join(", ")}`,
},
400,
);
}
const type = rawType as QuoteType;
const startDateParam = c.req.query("startDate");
const endDateParam = c.req.query("endDate");
const now = new Date();
const startDate = startDateParam
? parseLocalDate(startDateParam)
: new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = endDateParam ? startOfNextLocalDay(endDateParam) : now;
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
return c.json({ error: "Invalid date format. Use ISO 8601." }, 400);
}
const cacheKey = `quotes:peak-hours:${type}:${startDate.toISOString()}:${endDate.toISOString()}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const result = await prisma.$queryRaw<
Array<{
hour: number;
frequency: number;
avg_peak_buy: number;
min_peak_buy: number;
max_peak_buy: number;
}>
>`
WITH daily_peaks AS (
SELECT
DATE("timeStamp") AS day,
EXTRACT(HOUR FROM "timeStamp")::int AS peak_hour,
buy::numeric AS peak_buy,
ROW_NUMBER() OVER (
PARTITION BY DATE("timeStamp")
ORDER BY buy::numeric DESC
) AS rn
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
)
SELECT
peak_hour AS hour,
COUNT(*)::int AS frequency,
AVG(peak_buy)::float8 AS avg_peak_buy,
MIN(peak_buy)::float8 AS min_peak_buy,
MAX(peak_buy)::float8 AS max_peak_buy
FROM daily_peaks
WHERE rn = 1
GROUP BY peak_hour
ORDER BY peak_hour ASC
`;
const totalDays = await prisma.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(DISTINCT DATE("timeStamp"))::int AS count
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
`;
const response = {
totalDays: totalDays[0]?.count ?? 0,
peaks: result.map((r) => ({
hour: r.hour,
frequency: r.frequency,
avgPeakBuy: r.avg_peak_buy,
minPeakBuy: r.min_peak_buy,
maxPeakBuy: r.max_peak_buy,
})),
};
cache.set(cacheKey, response);
return c.json(response);
}

View File

@@ -5,6 +5,7 @@ import { getDailyMinMax } from "./handlers/getDailyMinMax";
import { getDailyQuotes } from "./handlers/getDailyQuotes";
import { getHistoricalMinMax } from "./handlers/getHistoricalMinMax";
import { getMonthlyLast } from "./handlers/getMonthlyLast";
import { getPeakHours } from "./handlers/getPeakHours";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { quoteEvents } from "./handlers/quoteEvents";
@@ -15,6 +16,7 @@ app.get("/monthly-last", getMonthlyLast);
app.get("/:type/history", getQuoteHistory);
app.get("/:type/daily", getDailyQuotes);
app.get("/:type/min-max", getDailyMinMax);
app.get("/:type/peak-hours", getPeakHours);
app.get("/:type/historical/min-max", getHistoricalMinMax);
app.get("/fetch", fetchQuotes);
app.get("/events", quoteEvents);