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 { cache } from "../../../lib/cache";
|
||||||
import { prisma } from "../../../lib/prisma";
|
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) {
|
export async function getMonthlyLast(c: Context) {
|
||||||
const cacheKey = "quotes:monthly-last";
|
const cacheKey = "quotes:monthly-last-v2";
|
||||||
const cached = cache.get(cacheKey);
|
const cached = cache.get(cacheKey);
|
||||||
if (cached) return c.json(cached);
|
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<{
|
Array<{
|
||||||
month: string;
|
month: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -19,10 +63,106 @@ export async function getMonthlyLast(c: Context) {
|
|||||||
"type"::text,
|
"type"::text,
|
||||||
(ARRAY_AGG(buy ORDER BY "timeStamp" DESC))[1]::numeric::float8 AS buy
|
(ARRAY_AGG(buy ORDER BY "timeStamp" DESC))[1]::numeric::float8 AS buy
|
||||||
FROM "quotes_history"
|
FROM "quotes_history"
|
||||||
|
WHERE "timeStamp" >= ${startDate}::timestamptz
|
||||||
|
AND "timeStamp" < ${endDate}::timestamptz
|
||||||
GROUP BY DATE_TRUNC('month', "timeStamp"), "type"
|
GROUP BY DATE_TRUNC('month', "timeStamp"), "type"
|
||||||
ORDER BY month ASC
|
ORDER BY month ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
cache.set(cacheKey, rows, 300);
|
const dbMap = new Map<string, Map<string, number>>();
|
||||||
return c.json(rows);
|
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