feat(analysis): add monthly pattern analysis feature with chart and API integration

This commit is contained in:
Jose Selesan
2026-09-14 16:41:21 -03:00
parent f2c26c266c
commit 28e1a732e0
7 changed files with 794 additions and 12 deletions

View File

@@ -0,0 +1,122 @@
import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const;
const ONE_HOUR = 60 * 60 * 1000;
export async function getMonthlyPattern(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 cacheKey = `quotes:monthly-pattern:${rawType}`;
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<
Array<{
day: number;
months: number;
avgBuy: number;
avgSell: number;
avgBuyPosition: number | null;
avgSellPosition: number | null;
minBuyDays: number;
maxBuyDays: number;
minSellDays: number;
maxSellDays: number;
}>
>`
WITH monthly AS (
SELECT
DATE_TRUNC('month', "timeStamp")::date AS month,
MIN(buy::numeric) AS min_buy,
MAX(buy::numeric) AS max_buy,
MIN(sell::numeric) AS min_sell,
MAX(sell::numeric) AS max_sell
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" < DATE_TRUNC('month', NOW())
GROUP BY DATE_TRUNC('month', "timeStamp")
),
daily AS (
SELECT
DATE_TRUNC('month', "timeStamp")::date AS month,
EXTRACT(DAY FROM "timeStamp")::int AS day,
AVG(buy::numeric) AS avg_buy,
AVG(sell::numeric) AS avg_sell,
MIN(buy::numeric) AS day_min_buy,
MAX(buy::numeric) AS day_max_buy,
MIN(sell::numeric) AS day_min_sell,
MAX(sell::numeric) AS day_max_sell
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" < DATE_TRUNC('month', NOW())
GROUP BY
DATE_TRUNC('month', "timeStamp"),
EXTRACT(DAY FROM "timeStamp")
)
SELECT
d.day,
COUNT(*)::int AS months,
AVG(d.avg_buy)::float8 AS "avgBuy",
AVG(d.avg_sell)::float8 AS "avgSell",
AVG(
CASE WHEN m.max_buy > m.min_buy
THEN (d.avg_buy - m.min_buy) / (m.max_buy - m.min_buy)
END
)::float8 AS "avgBuyPosition",
AVG(
CASE WHEN m.max_sell > m.min_sell
THEN (d.avg_sell - m.min_sell) / (m.max_sell - m.min_sell)
END
)::float8 AS "avgSellPosition",
COUNT(*) FILTER (WHERE d.day_min_buy = m.min_buy)::int AS "minBuyDays",
COUNT(*) FILTER (WHERE d.day_max_buy = m.max_buy)::int AS "maxBuyDays",
COUNT(*) FILTER (WHERE d.day_min_sell = m.min_sell)::int AS "minSellDays",
COUNT(*) FILTER (WHERE d.day_max_sell = m.max_sell)::int AS "maxSellDays"
FROM daily d
JOIN monthly m ON m.month = d.month
GROUP BY d.day
ORDER BY d.day ASC
`;
const totalMonths = await prisma.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(*)::int AS count
FROM (
SELECT DATE_TRUNC('month', "timeStamp")::date AS month
FROM "quotes_history"
WHERE "type" = ${rawType}::"QuoteType"
AND "timeStamp" < DATE_TRUNC('month', NOW())
GROUP BY DATE_TRUNC('month', "timeStamp")
) AS months
`;
const response = {
type: rawType,
totalMonths: totalMonths[0]?.count ?? 0,
days: rows.map((r) => ({
day: r.day,
months: r.months,
avgBuy: r.avgBuy,
avgSell: r.avgSell,
avgBuyPosition: r.avgBuyPosition,
avgSellPosition: r.avgSellPosition,
minBuyDays: r.minBuyDays,
maxBuyDays: r.maxBuyDays,
minSellDays: r.minSellDays,
maxSellDays: r.maxSellDays,
})),
};
cache.set(cacheKey, response, ONE_HOUR);
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 { getMonthlyPattern } from "./handlers/getMonthlyPattern";
import { getPeakHours } from "./handlers/getPeakHours";
import { getQuoteHistory } from "./handlers/getQuoteHistory";
import { quoteEvents } from "./handlers/quoteEvents";
@@ -17,6 +18,7 @@ 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/monthly-pattern", getMonthlyPattern);
app.get("/:type/historical/min-max", getHistoricalMinMax);
app.get("/fetch", fetchQuotes);
app.get("/events", quoteEvents);

View File

@@ -17,10 +17,12 @@ import {
useDailyQuotes,
useHistoricalMinMax,
useMonthlyLast,
useMonthlyPattern,
usePeakHours,
useQuotes,
} from "@/lib/queries";
import { MonthlyVariationTable } from "./components/MonthlyVariationTable";
import { MonthPatternChart } from "./components/MonthPatternChart";
import { PeakHoursChart } from "./components/PeakHoursChart";
const CURRENCIES = ["BELO", "BLUE", "BNA"] as const;
@@ -104,6 +106,9 @@ export function AnalysisPage({ currency }: { currency: string }) {
const { data: peakHoursData, isLoading: peakHoursLoading } =
usePeakHours("BELO");
const { data: monthlyPatternData, isLoading: monthlyPatternLoading } =
useMonthlyPattern("BELO");
const chartData = useMemo(() => {
if (!daily) return [];
return daily.map((d) => ({
@@ -598,16 +603,31 @@ export function AnalysisPage({ currency }: { currency: string }) {
</div>
{currency === "BELO" && (
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Análisis de pico horario
</h2>
<p className="text-xs text-muted-foreground mb-4">
Frecuencia con la que cada hora del día fue el momento de mayor
precio de compra dentro de cada día.
</p>
<PeakHoursChart data={peakHoursData} isLoading={peakHoursLoading} />
</div>
<>
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">
Análisis de pico horario
</h2>
<p className="text-xs text-muted-foreground mb-4">
Frecuencia con la que cada hora del día fue el momento de mayor
precio de compra dentro de cada día.
</p>
<PeakHoursChart data={peakHoursData} isLoading={peakHoursLoading} />
</div>
<div className="rounded-lg border p-4">
<h2 className="text-sm font-medium mb-4">Patrón mensual</h2>
<p className="text-xs text-muted-foreground mb-4">
Posición del precio dentro de cada mes (0% = mínimo del mes, 100%
= máximo), promediada entre los meses completos disponibles. Se
excluye la tendencia inflacionaria para comparar momentos del mes.
</p>
<MonthPatternChart
data={monthlyPatternData}
isLoading={monthlyPatternLoading}
/>
</div>
</>
)}
</div>
);

View File

@@ -0,0 +1,293 @@
import { useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { MonthlyPatternResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
const priceFormatter = new Intl.NumberFormat("es-AR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
type Mode = "sell" | "buy";
type ChartEntry = {
day: number;
months: number;
position: number;
avgPrice: number;
recommended: boolean;
};
export function MonthPatternChart({
data,
isLoading,
}: {
data: MonthlyPatternResponse | undefined;
isLoading: boolean;
}) {
const [mode, setMode] = useState<Mode>("sell");
const { chartData, totalMonths, bestSellDay, bestBuyDay, minCoverage } =
useMemo(() => {
const totalMonths = data?.totalMonths ?? 0;
const allDays = data?.days ?? [];
const byDay = new Map(allDays.map((d) => [d.day, d]));
const minCoverage = Math.max(3, Math.ceil(totalMonths * 0.5));
const eligible = allDays.filter((d) => d.months >= minCoverage);
let bestSellDay: number | null = null;
let bestSellPosition = -Infinity;
for (const d of eligible) {
const pos = d.avgBuyPosition ?? null;
if (pos !== null && pos > bestSellPosition) {
bestSellPosition = pos;
bestSellDay = d.day;
}
}
let bestBuyDay: number | null = null;
let bestBuyPosition = Infinity;
for (const d of eligible) {
const pos = d.avgSellPosition ?? null;
if (pos !== null && pos < bestBuyPosition) {
bestBuyPosition = pos;
bestBuyDay = d.day;
}
}
const recommended = mode === "sell" ? bestSellDay : bestBuyDay;
const chartData: ChartEntry[] = Array.from({ length: 31 }, (_, i) => {
const day = i + 1;
const found = byDay.get(day);
const position =
mode === "sell"
? (found?.avgBuyPosition ?? 0)
: (found?.avgSellPosition ?? 0);
return {
day,
months: found?.months ?? 0,
position,
avgPrice:
mode === "sell" ? (found?.avgBuy ?? 0) : (found?.avgSell ?? 0),
recommended: recommended !== null && day === recommended,
};
});
return {
chartData,
totalMonths,
bestSellDay,
bestBuyDay,
minCoverage,
};
}, [data, mode]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
Cargando datos del patrón mensual...
</div>
);
}
if (totalMonths === 0 || chartData.every((d) => d.months === 0)) {
return (
<div className="flex items-center justify-center h-64 text-muted-foreground text-sm">
Sin suficiente historial. Se necesitan al menos 2 meses completos de
datos.
</div>
);
}
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">
Mejor día para vender USDC
</p>
<p className="text-2xl font-bold">
{bestSellDay !== null ? `Día ${bestSellDay}` : "Sin datos"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Precio de compra más alto del mes
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">
Mejor día para comprar USDC
</p>
<p className="text-2xl font-bold">
{bestBuyDay !== null ? `Día ${bestBuyDay}` : "Sin datos"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Precio de venta más bajo del mes
</p>
</div>
<div className="rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">Meses analizados</p>
<p className="text-2xl font-bold tabular-nums">{totalMonths}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Mínimo de cobertura por día: {minCoverage} meses
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border p-0.5">
<button
type="button"
onClick={() => setMode("sell")}
className={cn(
"px-3 py-1 text-xs rounded-md transition-colors cursor-pointer",
mode === "sell"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Vender USDC (compra)
</button>
<button
type="button"
onClick={() => setMode("buy")}
className={cn(
"px-3 py-1 text-xs rounded-md transition-colors cursor-pointer",
mode === "buy"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
Comprar USDC (venta)
</button>
</div>
<p className="text-xs text-muted-foreground">
Posición promedio dentro del mes (0% = mínimo, 100% = máximo)
</p>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-border"
vertical={false}
/>
<XAxis
dataKey="day"
tick={{ fontSize: 10 }}
className="text-muted-foreground"
interval={2}
/>
<YAxis
tick={{ fontSize: 11 }}
className="text-muted-foreground"
domain={[0, 1]}
tickFormatter={(v: number) => `${Math.round(v * 100)}%`}
label={{
value: "Posición en el mes",
angle: -90,
position: "insideLeft",
offset: 10,
style: { fontSize: 11, fill: "hsl(var(--muted-foreground))" },
}}
/>
<Tooltip
cursor={{ fill: "hsl(var(--muted) / 0.3)" }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as ChartEntry;
return (
<div
className="rounded-lg border bg-card p-3 text-sm shadow-sm"
style={{ minWidth: 180 }}
>
<p className="font-semibold mb-1">Día {d.day}</p>
<div className="space-y-0.5 text-muted-foreground">
<p>
Posición:{" "}
<span className="font-medium text-foreground">
{Math.round(d.position * 100)}%
</span>
</p>
{d.avgPrice > 0 && (
<p>
Precio promedio:{" "}
<span className="font-medium text-foreground">
${priceFormatter.format(d.avgPrice)}
</span>
</p>
)}
<p>
Con datos en:{" "}
<span className="font-medium text-foreground">
{d.months} meses
</span>
</p>
{d.recommended && (
<p className="text-emerald-500 font-medium">
Día recomendado
</p>
)}
</div>
</div>
);
}}
/>
<Bar
dataKey="position"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
>
{chartData.map((entry) => (
<Cell
key={entry.day}
fill={
entry.recommended
? mode === "sell"
? "var(--chart-1)"
: "var(--chart-2)"
: "hsl(var(--muted-foreground) / 0.25)"
}
fillOpacity={entry.months === 0 ? 0.4 : 1}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
Mejor día para vender USDC
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
Mejor día para comprar USDC
</span>
</div>
</div>
</div>
);
}

View File

@@ -58,6 +58,25 @@ export type PeakHoursResponse = {
peaks: PeakHour[];
};
export type MonthPatternDay = {
day: number;
months: number;
avgBuy: number;
avgSell: number;
avgBuyPosition: number | null;
avgSellPosition: number | null;
minBuyDays: number;
maxBuyDays: number;
minSellDays: number;
maxSellDays: number;
};
export type MonthlyPatternResponse = {
type: string;
totalMonths: number;
days: MonthPatternDay[];
};
async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
@@ -126,6 +145,12 @@ export function getPeakHours(type: string): Promise<PeakHoursResponse> {
return fetcher<PeakHoursResponse>(`/api/quotes/${type}/peak-hours`);
}
export function getMonthlyPattern(
type: string,
): Promise<MonthlyPatternResponse> {
return fetcher<MonthlyPatternResponse>(`/api/quotes/${type}/monthly-pattern`);
}
export type MonthlyLast = {
month: string;
type: "BELO" | "BLUE" | "BNA";

View File

@@ -26,6 +26,7 @@ import {
getHistoricalMinMax,
getInflation,
getMonthlyLast,
getMonthlyPattern,
getMonthlyPayedTotal,
getMonthlyTotals,
getPeakHours,
@@ -64,8 +65,8 @@ export const quoteKeys = {
["quotes", type, "daily", startDate, endDate] as const,
historicalMinMax: (type: string) =>
["quotes", type, "historicalMinMax"] as const,
peakHours: (type: string) =>
["quotes", type, "peakHours"] as const,
peakHours: (type: string) => ["quotes", type, "peakHours"] as const,
monthlyPattern: (type: string) => ["quotes", type, "monthlyPattern"] as const,
};
export function useQuotes() {
@@ -143,6 +144,14 @@ export function usePeakHours(type: string) {
});
}
export function useMonthlyPattern(type: string) {
return useQuery({
queryKey: quoteKeys.monthlyPattern(type),
queryFn: () => getMonthlyPattern(type),
staleTime: 30_000,
});
}
export function useMonthlyLast() {
return useQuery({
queryKey: ["quotes", "monthlyLast"] as const,