feat(analysis): rolling 12-month table with external API fallback

- monthly-last endpoint now returns last 12 months (rolling)
- For months without DB data, fetches from ArgentinaDatos API
- Casa mapping: BELO=cripto, BLUE=blue, BNA=oficial
- DB data takes priority over external API data
This commit is contained in:
Jose Selesan
2026-08-27 11:33:45 -03:00
parent 873fd63ea4
commit 28f9dea7ba

View File

@@ -2,12 +2,56 @@ import type { Context } from "hono";
import { cache } from "../../../lib/cache";
import { prisma } from "../../../lib/prisma";
type MonthlyLastRow = {
month: string;
type: string;
buy: number;
};
type ExternalQuote = {
casa: string;
fecha: string;
compra: number;
venta: number;
};
const CASA_MAP: Record<string, string> = {
BELO: "cripto",
BLUE: "blue",
BNA: "oficial",
};
const TYPES = ["BELO", "BLUE", "BNA"] as const;
function getMonthKey(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
function subtractMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() - months);
return result;
}
export async function getMonthlyLast(c: Context) {
const cacheKey = "quotes:monthly-last";
const cacheKey = "quotes:monthly-last-v2";
const cached = cache.get(cacheKey);
if (cached) return c.json(cached);
const rows = await prisma.$queryRaw<
const today = new Date();
const currentMonthKey = getMonthKey(today);
const startMonth = subtractMonths(today, 11);
const startDate = new Date(
startMonth.getFullYear(),
startMonth.getMonth(),
1,
);
const endDate = new Date(today.getFullYear(), today.getMonth() + 1, 1);
const dbRows = await prisma.$queryRaw<
Array<{
month: string;
type: string;
@@ -19,10 +63,106 @@ export async function getMonthlyLast(c: Context) {
"type"::text,
(ARRAY_AGG(buy ORDER BY "timeStamp" DESC))[1]::numeric::float8 AS buy
FROM "quotes_history"
WHERE "timeStamp" >= ${startDate}::timestamptz
AND "timeStamp" < ${endDate}::timestamptz
GROUP BY DATE_TRUNC('month', "timeStamp"), "type"
ORDER BY month ASC
`;
cache.set(cacheKey, rows, 300);
return c.json(rows);
const dbMap = new Map<string, Map<string, number>>();
for (const row of dbRows) {
if (!dbMap.has(row.month)) dbMap.set(row.month, new Map());
dbMap.get(row.month)?.set(row.type, row.buy);
}
const expectedMonths: string[] = [];
let cursor = new Date(startMonth.getFullYear(), startMonth.getMonth(), 1);
while (getMonthKey(cursor) <= currentMonthKey) {
expectedMonths.push(getMonthKey(cursor));
cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1);
}
const missingByType = new Map<string, string[]>();
for (const type of TYPES) {
const missing = expectedMonths.filter((m) => !dbMap.get(m)?.has(type));
if (missing.length > 0) missingByType.set(type, missing);
}
if (missingByType.size === 0) {
const result: MonthlyLastRow[] = [];
for (const month of expectedMonths) {
for (const type of TYPES) {
const buy = dbMap.get(month)?.get(type);
if (buy !== undefined) {
result.push({ month, type, buy });
}
}
}
cache.set(cacheKey, result, 300);
return c.json(result);
}
const neededMonths = new Set<string>();
for (const months of missingByType.values()) {
for (const m of months) neededMonths.add(m);
}
const sortedNeeded = [...neededMonths].sort();
const minNeeded = sortedNeeded[0];
const maxNeeded = sortedNeeded[sortedNeeded.length - 1];
const externalData = new Map<string, Map<string, number>>();
for (const [type, casa] of Object.entries(CASA_MAP)) {
if (!missingByType.has(type)) continue;
try {
const res = await fetch(
`https://api.argentinadatos.com/v1/cotizaciones/dolares/${casa}`,
);
if (!res.ok) continue;
const quotes: ExternalQuote[] = await res.json();
const monthLast = new Map<string, { fecha: string; compra: number }>();
for (const q of quotes) {
const fecha = q.fecha.slice(0, 10);
if (fecha < minNeeded || fecha > maxNeeded) continue;
const monthKey = fecha.slice(0, 7);
if (!neededMonths.has(monthKey)) continue;
const existing = monthLast.get(monthKey);
if (!existing || fecha > existing.fecha) {
monthLast.set(monthKey, { fecha, compra: q.compra });
}
}
const typeMap = new Map<string, number>();
for (const [month, data] of monthLast) {
typeMap.set(month, data.compra);
}
externalData.set(type, typeMap);
} catch {
// If external API fails, continue without those values
}
}
const result: MonthlyLastRow[] = [];
for (const month of expectedMonths) {
for (const type of TYPES) {
const dbBuy = dbMap.get(month)?.get(type);
if (dbBuy !== undefined) {
result.push({ month, type, buy: dbBuy });
continue;
}
const extBuy = externalData.get(type)?.get(month);
if (extBuy !== undefined) {
result.push({ month, type, buy: extBuy });
}
}
}
cache.set(cacheKey, result, 300);
return c.json(result);
}