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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user