feat(analysis): add monthly pattern analysis feature with chart and API integration
This commit is contained in:
122
apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts
Normal file
122
apps/backend/src/modules/quotes/handlers/getMonthlyPattern.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user