Add files using upload-large-folder tool
Browse files- backend/src/alerts/alerts.controller.js +19 -0
- backend/src/alerts/alerts.repository.js +37 -0
- backend/src/alerts/alerts.routes.js +20 -0
- backend/src/alerts/alerts.service.js +60 -0
- backend/src/alerts/telegram.client.js +60 -0
- backend/src/finnhub/finnhub.client.js +126 -0
- backend/src/finnhub/finnhub.service.js +502 -0
- backend/src/markets/markets.controller.js +36 -0
- backend/src/markets/markets.repository.js +120 -0
- backend/src/markets/markets.routes.js +22 -0
- backend/src/markets/markets.service.js +55 -0
- backend/src/markets/markets.validators.js +24 -0
- backend/src/markets/polymarket.client.js +397 -0
- backend/src/positions/kelly.js +95 -0
- backend/src/positions/positions.repository.js +43 -0
- backend/src/positions/positions.routes.js +29 -0
- backend/src/positions/positions.service.js +119 -0
- backend/src/utils/coingecko.client.js +100 -0
- backend/src/utils/httpClient.js +88 -0
- backend/src/utils/prisma.js +26 -0
backend/src/alerts/alerts.controller.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Controladores del modulo de alertas.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - list(req, res) → devuelve el historial de alertas del usuario autenticado.
|
| 6 |
+
*
|
| 7 |
+
* Endpoint (bajo /api/v1/alerts, protegido por requireAuth):
|
| 8 |
+
* GET / → lista paginada de alertas.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { ok } from '../utils/apiResponse.js';
|
| 12 |
+
import { alertsService } from './alerts.service.js';
|
| 13 |
+
|
| 14 |
+
export const alertsController = {
|
| 15 |
+
async list(req, res) {
|
| 16 |
+
const alerts = await alertsService.list(req.user.id, req.query);
|
| 17 |
+
ok(res, alerts);
|
| 18 |
+
},
|
| 19 |
+
};
|
backend/src/alerts/alerts.repository.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Repositorio de acceso a datos para el modelo Alert.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - create(data) → inserta una nueva alerta.
|
| 6 |
+
* - findByUser(...) → alertas paginadas del usuario con datos del mercado.
|
| 7 |
+
* - findRecent(...) → busca alerta reciente en ventana de tiempo (deduplicacion).
|
| 8 |
+
*
|
| 9 |
+
* Campos:
|
| 10 |
+
* type (price_threshold|signal_change), message, sentAt.
|
| 11 |
+
*
|
| 12 |
+
* Todas las operaciones usan Prisma ORM.
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
import { prisma } from '../utils/prisma.js';
|
| 16 |
+
|
| 17 |
+
export const alertsRepository = {
|
| 18 |
+
create({ userId, marketId, type, message }) {
|
| 19 |
+
return prisma.alert.create({ data: { userId, marketId, type, message } });
|
| 20 |
+
},
|
| 21 |
+
|
| 22 |
+
findByUser(userId, { limit = 50, offset = 0 } = {}) {
|
| 23 |
+
return prisma.alert.findMany({
|
| 24 |
+
where: { userId },
|
| 25 |
+
orderBy: { sentAt: 'desc' },
|
| 26 |
+
take: limit,
|
| 27 |
+
skip: offset,
|
| 28 |
+
include: { market: { select: { id: true, question: true } } },
|
| 29 |
+
});
|
| 30 |
+
},
|
| 31 |
+
|
| 32 |
+
findRecent(userId, marketId, type, windowMs) {
|
| 33 |
+
return prisma.alert.findFirst({
|
| 34 |
+
where: { userId, marketId, type, sentAt: { gte: new Date(Date.now() - windowMs) } },
|
| 35 |
+
});
|
| 36 |
+
},
|
| 37 |
+
};
|
backend/src/alerts/alerts.routes.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Rutas REST del modulo de alertas.
|
| 3 |
+
*
|
| 4 |
+
* Endpoint (montado en /api/v1/alerts):
|
| 5 |
+
* GET / → listar alertas del usuario autenticado.
|
| 6 |
+
*
|
| 7 |
+
* Requiere autenticacion JWT (router.use(requireAuth)).
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import { Router } from 'express';
|
| 11 |
+
import { requireAuth } from '../middlewares/requireAuth.js';
|
| 12 |
+
import { alertsController } from './alerts.controller.js';
|
| 13 |
+
|
| 14 |
+
const router = Router();
|
| 15 |
+
|
| 16 |
+
router.use(requireAuth);
|
| 17 |
+
|
| 18 |
+
router.get('/', alertsController.list);
|
| 19 |
+
|
| 20 |
+
export default router;
|
backend/src/alerts/alerts.service.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Logica de negocio del modulo de alertas.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - list(userId, query) → devuelve alertas paginadas del usuario.
|
| 6 |
+
* - processAll() → revisa la watchlist cada 60s (scheduler):
|
| 7 |
+
* 1. Obtiene entradas de watchlist con alertThreshold.
|
| 8 |
+
* 2. Si yesPrice >= alertThreshold y no hay alerta reciente (5 min dedup):
|
| 9 |
+
* - Crea alerta en base de datos.
|
| 10 |
+
* - Envía mensaje por Telegram (si hay bot token y chatId).
|
| 11 |
+
* - Emite evento 'price_alert' por Socket.io.
|
| 12 |
+
*
|
| 13 |
+
* Tipos de alerta:
|
| 14 |
+
* - price_threshold : umbral de precio cruzado.
|
| 15 |
+
* - signal_change : cambio de senal IA (reservado para futuras versiones).
|
| 16 |
+
*
|
| 17 |
+
* Consumido por:
|
| 18 |
+
* - alerts.controller.js (API REST).
|
| 19 |
+
* - scheduler.js (processAlerts cada 60s).
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import { z } from 'zod';
|
| 23 |
+
import { alertsRepository } from './alerts.repository.js';
|
| 24 |
+
import { watchlistRepository } from '../watchlist/watchlist.repository.js';
|
| 25 |
+
import { sendMessage, formatPriceAlert } from './telegram.client.js';
|
| 26 |
+
import { emitPriceAlert } from '../socket/broadcaster.js';
|
| 27 |
+
import { logger } from '../utils/logger.js';
|
| 28 |
+
|
| 29 |
+
const listQuery = z.object({
|
| 30 |
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
| 31 |
+
offset: z.coerce.number().int().min(0).default(0),
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min
|
| 35 |
+
|
| 36 |
+
export const alertsService = {
|
| 37 |
+
list(userId, query) {
|
| 38 |
+
const { limit, offset } = listQuery.parse(query);
|
| 39 |
+
return alertsRepository.findByUser(userId, { limit, offset });
|
| 40 |
+
},
|
| 41 |
+
|
| 42 |
+
async processAll() {
|
| 43 |
+
const entries = await watchlistRepository.findAllWithThreshold();
|
| 44 |
+
|
| 45 |
+
for (const entry of entries) {
|
| 46 |
+
const { alertThreshold, user, market } = entry;
|
| 47 |
+
if (!market.yesPrice || market.yesPrice < alertThreshold) continue;
|
| 48 |
+
|
| 49 |
+
const recent = await alertsRepository.findRecent(user.id, market.id, 'price_threshold', DEDUP_WINDOW_MS);
|
| 50 |
+
if (recent) continue;
|
| 51 |
+
|
| 52 |
+
const message = formatPriceAlert(market.question, market.yesPrice, alertThreshold);
|
| 53 |
+
|
| 54 |
+
await alertsRepository.create({ userId: user.id, marketId: market.id, type: 'price_threshold', message });
|
| 55 |
+
await sendMessage(user.telegramChatId, message);
|
| 56 |
+
emitPriceAlert({ marketId: market.id, type: 'price_threshold', message });
|
| 57 |
+
logger.info({ marketId: market.id, userId: user.id }, 'price alert sent');
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
};
|
backend/src/alerts/telegram.client.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente de integracion con Telegram Bot API.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - sendMessage(chatId, text) → POST a api.telegram.org/bot<TOKEN>/sendMessage.
|
| 6 |
+
* - formatPriceAlert(question, yesPrice, threshold) → construye mensaje HTML
|
| 7 |
+
* escapando entidades del input de usuario.
|
| 8 |
+
* - escapeHtml(text) → escapa caracteres HTML sensibles (&, <, >, ").
|
| 9 |
+
* - Parse_mode: HTML (permite formato basico: <b>, <i>).
|
| 10 |
+
* - Si falta TELEGRAM_BOT_TOKEN o chatId, sendMessage retorna silenciosamente.
|
| 11 |
+
* - Errores de red se capturan y loguean como warn (no bloquean el flujo).
|
| 12 |
+
* - Respuestas de Telegram con ok: false se loguean como warn y no lanzan excepcion.
|
| 13 |
+
*
|
| 14 |
+
* Consumido por:
|
| 15 |
+
* - alerts.service.js → processAll() cuando se dispara una alerta.
|
| 16 |
+
*
|
| 17 |
+
* Seguridad:
|
| 18 |
+
* - El token se lee de variables de entorno (nunca hardcodeado).
|
| 19 |
+
* - Todo texto dinamico se escapa via escapeHtml antes de inyectarse en HTML.
|
| 20 |
+
*/
|
| 21 |
+
|
| 22 |
+
import { httpPost } from '../utils/httpClient.js';
|
| 23 |
+
import { config } from '../config.js';
|
| 24 |
+
import { logger } from '../utils/logger.js';
|
| 25 |
+
|
| 26 |
+
export function escapeHtml(text) {
|
| 27 |
+
if (typeof text !== 'string') return String(text ?? '');
|
| 28 |
+
return text
|
| 29 |
+
.replace(/&/g, '&')
|
| 30 |
+
.replace(/</g, '<')
|
| 31 |
+
.replace(/>/g, '>')
|
| 32 |
+
.replace(/"/g, '"');
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function formatPriceAlert(question, yesPrice, threshold) {
|
| 36 |
+
const safeQuestion = escapeHtml(question);
|
| 37 |
+
const pct = (yesPrice * 100).toFixed(1);
|
| 38 |
+
const thr = (threshold * 100).toFixed(1);
|
| 39 |
+
return `<b>Price Alert</b>\n${safeQuestion}\nYES: ${pct}% ≥ threshold ${thr}%`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function sendMessage(chatId, text) {
|
| 43 |
+
if (!config.TELEGRAM_BOT_TOKEN || !chatId) return;
|
| 44 |
+
try {
|
| 45 |
+
const result = await httpPost(
|
| 46 |
+
`https://api.telegram.org/bot${config.TELEGRAM_BOT_TOKEN}/sendMessage`,
|
| 47 |
+
{ chat_id: chatId, text, parse_mode: 'HTML' },
|
| 48 |
+
{ retries: 1, timeout: 8_000 },
|
| 49 |
+
);
|
| 50 |
+
if (result && result.ok === false) {
|
| 51 |
+
logger.warn(
|
| 52 |
+
{ chatId, description: result.description, errorCode: result.error_code },
|
| 53 |
+
'telegram API returned ok=false',
|
| 54 |
+
);
|
| 55 |
+
return;
|
| 56 |
+
}
|
| 57 |
+
} catch (err) {
|
| 58 |
+
logger.warn({ err: err.message, chatId }, 'telegram send failed');
|
| 59 |
+
}
|
| 60 |
+
}
|
backend/src/finnhub/finnhub.client.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente HTTP de integracion con Finnhub REST API.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - Realizar peticiones GET a los endpoints de Finnhub usando httpClient.js
|
| 6 |
+
* para obtener retry, timeout y manejo de errores automatico.
|
| 7 |
+
* - getCompanyNews(symbol, daysBack) → noticias de empresa en rango de fechas.
|
| 8 |
+
* - getMarketNews(category, limit) → noticias generales por categoria.
|
| 9 |
+
*
|
| 10 |
+
* Restricciones:
|
| 11 |
+
* - Free tier: maximo 60 llamadas/minuto.
|
| 12 |
+
* - Solo se invoca durante la generacion de senales (cada 5 min).
|
| 13 |
+
* - Si FINNHUB_API_KEY no esta configurada, devuelve array vacio.
|
| 14 |
+
*
|
| 15 |
+
* Consumido por:
|
| 16 |
+
* - finnhub.service.js → fetchFinancialNews(), pipeline de IA.
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
import { httpGet } from '../utils/httpClient.js';
|
| 20 |
+
import { config } from '../config.js';
|
| 21 |
+
import { logger } from '../utils/logger.js';
|
| 22 |
+
|
| 23 |
+
const FINNHUB_API_URL = 'https://finnhub.io/api/v1';
|
| 24 |
+
const API_KEY = config.FINNHUB_API_KEY ?? '';
|
| 25 |
+
|
| 26 |
+
// Rate limiter interno: maximo 60 llamadas por minuto a Finnhub
|
| 27 |
+
const MAX_CALLS_PER_MINUTE = 60;
|
| 28 |
+
let callTimestamps = [];
|
| 29 |
+
|
| 30 |
+
function canMakeCall() {
|
| 31 |
+
const now = Date.now();
|
| 32 |
+
const oneMinuteAgo = now - 60_000;
|
| 33 |
+
callTimestamps = callTimestamps.filter((ts) => ts > oneMinuteAgo);
|
| 34 |
+
return callTimestamps.length < MAX_CALLS_PER_MINUTE;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function recordCall() {
|
| 38 |
+
callTimestamps.push(Date.now());
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function ensureApiKey() {
|
| 42 |
+
return !!API_KEY;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Normaliza un item de noticia de Finnhub a formato estandar interno.
|
| 47 |
+
*
|
| 48 |
+
* @param {Object} item
|
| 49 |
+
* @returns {Object}
|
| 50 |
+
*/
|
| 51 |
+
function normalizeItem(item) {
|
| 52 |
+
return {
|
| 53 |
+
id: item.id || item.url || null,
|
| 54 |
+
headline: item.headline || item.title || '',
|
| 55 |
+
summary: item.summary || '',
|
| 56 |
+
url: item.url || null,
|
| 57 |
+
source: item.source || null,
|
| 58 |
+
related: item.related || null,
|
| 59 |
+
datetime: item.datetime ? new Date(item.datetime * 1000) : null,
|
| 60 |
+
};
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Obtiene noticias de una empresa especifica en un rango de fechas.
|
| 65 |
+
*
|
| 66 |
+
* @param {string} symbol - Simbolo bursatil (ej: AAPL, TSLA).
|
| 67 |
+
* @param {number} [daysBack=7] - Dias hacia atras.
|
| 68 |
+
* @returns {Promise<Object[]>} Noticias normalizadas (array vacio si no hay API key o se excede rate limit).
|
| 69 |
+
*/
|
| 70 |
+
export async function getCompanyNews(symbol, daysBack = 7) {
|
| 71 |
+
if (!ensureApiKey()) {
|
| 72 |
+
logger.debug('Finnhub API key not configured, returning empty array');
|
| 73 |
+
return [];
|
| 74 |
+
}
|
| 75 |
+
if (!canMakeCall()) {
|
| 76 |
+
logger.warn('Finnhub rate limit exceeded (60 calls/min), returning empty array');
|
| 77 |
+
return [];
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
const to = new Date();
|
| 81 |
+
const from = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000);
|
| 82 |
+
const fromStr = from.toISOString().slice(0, 10);
|
| 83 |
+
const toStr = to.toISOString().slice(0, 10);
|
| 84 |
+
|
| 85 |
+
const url = `${FINNHUB_API_URL}/company-news?symbol=${encodeURIComponent(symbol)}&from=${fromStr}&to=${toStr}&token=${API_KEY}`;
|
| 86 |
+
|
| 87 |
+
try {
|
| 88 |
+
recordCall();
|
| 89 |
+
const data = await httpGet(url, { timeout: 10_000, retries: 1 });
|
| 90 |
+
if (!Array.isArray(data)) return [];
|
| 91 |
+
return data.map(normalizeItem);
|
| 92 |
+
} catch (err) {
|
| 93 |
+
logger.warn({ err: err.message, symbol }, 'Finnhub getCompanyNews failed');
|
| 94 |
+
return [];
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/**
|
| 99 |
+
* Obtiene noticias del mercado por categoria.
|
| 100 |
+
*
|
| 101 |
+
* @param {string} [category='general'] - Categoria: general, forex, crypto, merger.
|
| 102 |
+
* @param {number} [limit=50] - Maximo de noticias.
|
| 103 |
+
* @returns {Promise<Object[]>} Noticias normalizadas (array vacio si no hay API key o se excede rate limit).
|
| 104 |
+
*/
|
| 105 |
+
export async function getMarketNews(category = 'general', limit = 50) {
|
| 106 |
+
if (!ensureApiKey()) {
|
| 107 |
+
logger.debug('Finnhub API key not configured, returning empty array');
|
| 108 |
+
return [];
|
| 109 |
+
}
|
| 110 |
+
if (!canMakeCall()) {
|
| 111 |
+
logger.warn('Finnhub rate limit exceeded (60 calls/min), returning empty array');
|
| 112 |
+
return [];
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const url = `${FINNHUB_API_URL}/news?category=${encodeURIComponent(category)}&token=${API_KEY}`;
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
recordCall();
|
| 119 |
+
const data = await httpGet(url, { timeout: 10_000, retries: 1 });
|
| 120 |
+
if (!Array.isArray(data)) return [];
|
| 121 |
+
return data.slice(0, limit).map(normalizeItem);
|
| 122 |
+
} catch (err) {
|
| 123 |
+
logger.warn({ err: err.message, category }, 'Finnhub getMarketNews failed');
|
| 124 |
+
return [];
|
| 125 |
+
}
|
| 126 |
+
}
|
backend/src/finnhub/finnhub.service.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Logica de negocio del modulo de noticias Finnhub.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - fetchFinancialNewsForMarket(market) → obtiene noticias financieras
|
| 6 |
+
* RELEVANTES para un mercado especifico de Polymarket.
|
| 7 |
+
* - Mapeo inteligente de mercados a simbolos bursatiles (bitcoin → BTC, Coinbase, etc.)
|
| 8 |
+
* - Deduplicacion, scoring de relevancia, y limitacion.
|
| 9 |
+
*
|
| 10 |
+
* Filosofia:
|
| 11 |
+
* Finnhub cubre noticias financieras. Si un mercado de Polymarket NO tiene
|
| 12 |
+
* correlacion financiera directa (ej: GTA VI, Weinstein, deportes), NO enviamos
|
| 13 |
+
* noticias irrelevantes de bitcoin. En esos casos, Qwen analiza solo el precio
|
| 14 |
+
* del mercado y momentum.
|
| 15 |
+
*
|
| 16 |
+
* Si el mercado SI tiene correlacion financiera (bitcoin, crypto, stocks, forex),
|
| 17 |
+
* buscamos noticias especificas de los simbolos/sectores asociados usando
|
| 18 |
+
* company-news de Finnhub para mayor precision.
|
| 19 |
+
*
|
| 20 |
+
* Mejoras:
|
| 21 |
+
* - Cache TTL de 5 min para company-news por simbolo.
|
| 22 |
+
* - Deduplicacion por headline normalizado.
|
| 23 |
+
* - Maximo 8 noticias relevantes por mercado.
|
| 24 |
+
* - Shuffle para diversidad entre mercados.
|
| 25 |
+
*
|
| 26 |
+
* Consumido por:
|
| 27 |
+
* - signals/aiPipeline.js → fase 1 de filtrado de noticias.
|
| 28 |
+
*/
|
| 29 |
+
|
| 30 |
+
import { getCompanyNews, getMarketNews } from './finnhub.client.js';
|
| 31 |
+
import { logger } from '../utils/logger.js';
|
| 32 |
+
|
| 33 |
+
const MAX_NEWS_PER_MARKET = 8;
|
| 34 |
+
const NEWS_CACHE_TTL_MS = 30 * 1000; // 30 segundos (noticias frescas)
|
| 35 |
+
|
| 36 |
+
// Cache por simbolo
|
| 37 |
+
const companyNewsCache = new Map(); // symbol → { data, timestamp }
|
| 38 |
+
const generalNewsCache = { data: null, timestamp: 0 };
|
| 39 |
+
|
| 40 |
+
// ── Mapeo inteligente: mercados → simbolos financieros ────────────────────────
|
| 41 |
+
|
| 42 |
+
const FINANCIAL_KEYWORDS = {
|
| 43 |
+
// Crypto
|
| 44 |
+
bitcoin: ['BTC', 'COIN', 'MSTR', 'RIOT', 'MARA'],
|
| 45 |
+
btc: ['BTC', 'COIN', 'MSTR'],
|
| 46 |
+
crypto: ['COIN', 'BTC', 'ETH', 'SOL', 'MSTR'],
|
| 47 |
+
ethereum: ['ETH', 'COIN'],
|
| 48 |
+
eth: ['ETH', 'COIN'],
|
| 49 |
+
solana: ['SOL', 'COIN'],
|
| 50 |
+
blockchain: ['COIN', 'BTC', 'IBM'],
|
| 51 |
+
altcoin: ['COIN', 'ETH', 'SOL'],
|
| 52 |
+
defi: ['COIN', 'ETH'],
|
| 53 |
+
stablecoin: ['COIN', 'CRCL'],
|
| 54 |
+
|
| 55 |
+
// Forex / Macro
|
| 56 |
+
dollar: ['DXY', 'UUP'],
|
| 57 |
+
euro: ['FXE'],
|
| 58 |
+
yen: ['FXY'],
|
| 59 |
+
pound: ['FXB'],
|
| 60 |
+
inflation: ['DXY', 'TLT', 'GLD'],
|
| 61 |
+
cpi: ['SPY', 'TLT', 'GLD'],
|
| 62 |
+
recession: ['SPY', 'QQQ', 'VIX', 'TLT'],
|
| 63 |
+
'interest rate': ['TLT', 'TBT', 'DXY'],
|
| 64 |
+
'rate cut': ['TLT', 'SPY', 'QQQ'],
|
| 65 |
+
'rate hike': ['TBT', 'DXY'],
|
| 66 |
+
fed: ['TLT', 'SPY', 'DXY'],
|
| 67 |
+
powell: ['TLT', 'SPY'],
|
| 68 |
+
ecb: ['FXE', 'EWG'],
|
| 69 |
+
boj: ['FXY', 'EWJ'],
|
| 70 |
+
unemployment: ['SPY', 'TLT'],
|
| 71 |
+
gdp: ['SPY', 'DXY'],
|
| 72 |
+
tariff: ['SPY', 'FXI', 'XLI'],
|
| 73 |
+
'trade war': ['FXI', 'SPY', 'GLD'],
|
| 74 |
+
|
| 75 |
+
// Tech / Stocks
|
| 76 |
+
apple: ['AAPL'],
|
| 77 |
+
tesla: ['TSLA'],
|
| 78 |
+
nvidia: ['NVDA'],
|
| 79 |
+
amazon: ['AMZN'],
|
| 80 |
+
google: ['GOOGL'],
|
| 81 |
+
microsoft: ['MSFT'],
|
| 82 |
+
meta: ['META'],
|
| 83 |
+
facebook: ['META'],
|
| 84 |
+
openai: ['MSFT', 'NVDA'],
|
| 85 |
+
anthropic: ['GOOGL', 'AMZN'],
|
| 86 |
+
'artificial intelligence': ['NVDA', 'MSFT', 'GOOGL', 'META'],
|
| 87 |
+
ai: ['NVDA', 'MSFT', 'GOOGL', 'META'],
|
| 88 |
+
semiconductor: ['NVDA', 'TSM', 'AVGO', 'AMD'],
|
| 89 |
+
chip: ['NVDA', 'TSM', 'AVGO', 'AMD'],
|
| 90 |
+
spacex: ['TSLA', 'RKLB'],
|
| 91 |
+
starlink: ['TSLA'],
|
| 92 |
+
boeing: ['BA'],
|
| 93 |
+
airbus: ['EADSY'],
|
| 94 |
+
|
| 95 |
+
// Commodities
|
| 96 |
+
gold: ['GLD', 'IAU'],
|
| 97 |
+
silver: ['SLV'],
|
| 98 |
+
oil: ['USO', 'XLE'],
|
| 99 |
+
energy: ['XLE', 'USO'],
|
| 100 |
+
natgas: ['UNG'],
|
| 101 |
+
uranium: ['URA', 'CCJ'],
|
| 102 |
+
|
| 103 |
+
// Market indices
|
| 104 |
+
's&p': ['SPY', 'SPX'],
|
| 105 |
+
sp500: ['SPY'],
|
| 106 |
+
nasdaq: ['QQQ'],
|
| 107 |
+
'stock market': ['SPY', 'QQQ', 'VIX'],
|
| 108 |
+
vix: ['VIX', 'SPY'],
|
| 109 |
+
|
| 110 |
+
// Geopolitical risk (afecta mercados) - elections impact VIX/DXY/defense
|
| 111 |
+
war: ['GLD', 'USO', 'DXY', 'VIX', 'LMT', 'RTX'],
|
| 112 |
+
conflict: ['GLD', 'USO', 'VIX', 'LMT'],
|
| 113 |
+
invasion: ['GLD', 'USO', 'VIX', 'LMT'],
|
| 114 |
+
ceasefire: ['SPY', 'USO'],
|
| 115 |
+
sanctions: ['USO', 'GLD', 'DXY'],
|
| 116 |
+
iran: ['USO', 'GLD', 'LMT'],
|
| 117 |
+
israel: ['USO', 'GLD', 'LMT'],
|
| 118 |
+
gaza: ['USO', 'GLD'],
|
| 119 |
+
hamas: ['USO', 'GLD'],
|
| 120 |
+
hezbollah: ['USO', 'GLD'],
|
| 121 |
+
ukraine: ['USO', 'GLD', 'LMT', 'RTX'],
|
| 122 |
+
russia: ['USO', 'GLD', 'LMT'],
|
| 123 |
+
putin: ['USO', 'GLD', 'LMT'],
|
| 124 |
+
china: ['FXI', 'USO', 'SPY'],
|
| 125 |
+
xi: ['FXI', 'SPY'],
|
| 126 |
+
taiwan: ['TSM', 'FXI', 'LMT'],
|
| 127 |
+
'north korea': ['LMT', 'EWY'],
|
| 128 |
+
venezuela: ['USO'],
|
| 129 |
+
saudi: ['USO', 'XLE'],
|
| 130 |
+
opec: ['USO', 'XLE'],
|
| 131 |
+
|
| 132 |
+
// Politica USA - mueve VIX/DXY/SPY
|
| 133 |
+
trump: ['SPY', 'DXY', 'VIX', 'XLE'],
|
| 134 |
+
biden: ['SPY', 'DXY', 'VIX'],
|
| 135 |
+
harris: ['SPY', 'VIX'],
|
| 136 |
+
desantis: ['SPY', 'XLE'],
|
| 137 |
+
vance: ['SPY', 'XLE'],
|
| 138 |
+
election: ['SPY', 'VIX', 'DXY'],
|
| 139 |
+
'presidential election': ['SPY', 'VIX', 'DXY', 'GLD'],
|
| 140 |
+
republican: ['SPY', 'XLE', 'LMT'],
|
| 141 |
+
democrat: ['SPY', 'TLT'],
|
| 142 |
+
congress: ['SPY', 'TLT'],
|
| 143 |
+
senate: ['SPY', 'TLT'],
|
| 144 |
+
shutdown: ['SPY', 'TLT', 'VIX'],
|
| 145 |
+
'debt ceiling': ['TLT', 'GLD', 'DXY'],
|
| 146 |
+
impeach: ['SPY', 'VIX'],
|
| 147 |
+
scotus: ['SPY'],
|
| 148 |
+
'supreme court': ['SPY'],
|
| 149 |
+
|
| 150 |
+
// Politica internacional
|
| 151 |
+
macron: ['EWQ', 'FXE'],
|
| 152 |
+
'le pen': ['EWQ', 'FXE'],
|
| 153 |
+
merkel: ['EWG', 'FXE'],
|
| 154 |
+
scholz: ['EWG', 'FXE'],
|
| 155 |
+
meloni: ['EWI', 'FXE'],
|
| 156 |
+
starmer: ['EWU', 'FXB'],
|
| 157 |
+
milei: ['ARGT'],
|
| 158 |
+
lula: ['EWZ'],
|
| 159 |
+
bolsonaro: ['EWZ'],
|
| 160 |
+
erdogan: ['TUR'],
|
| 161 |
+
modi: ['INDA'],
|
| 162 |
+
zelensky: ['USO', 'LMT'],
|
| 163 |
+
netanyahu: ['USO', 'GLD'],
|
| 164 |
+
|
| 165 |
+
// Entretenimiento con accionables financieros
|
| 166 |
+
netflix: ['NFLX'],
|
| 167 |
+
disney: ['DIS'],
|
| 168 |
+
marvel: ['DIS'],
|
| 169 |
+
spotify: ['SPOT'],
|
| 170 |
+
paramount: ['PARA'],
|
| 171 |
+
warner: ['WBD'],
|
| 172 |
+
'box office': ['DIS', 'WBD', 'NFLX'],
|
| 173 |
+
oscar: ['NFLX', 'DIS', 'WBD'],
|
| 174 |
+
gta: ['TTWO'],
|
| 175 |
+
rockstar: ['TTWO'],
|
| 176 |
+
'video game': ['TTWO', 'EA', 'ATVI'],
|
| 177 |
+
|
| 178 |
+
// Salud / pandemia
|
| 179 |
+
vaccine: ['PFE', 'MRNA', 'JNJ'],
|
| 180 |
+
fda: ['PFE', 'MRNA', 'LLY'],
|
| 181 |
+
pandemic: ['PFE', 'MRNA', 'VIX'],
|
| 182 |
+
'avian flu': ['PFE', 'MRNA'],
|
| 183 |
+
ozempic: ['NVO', 'LLY'],
|
| 184 |
+
|
| 185 |
+
// Clima/eventos
|
| 186 |
+
hurricane: ['XLE', 'HD', 'LOW'],
|
| 187 |
+
climate: ['ICLN', 'TAN', 'XLE'],
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
/**
|
| 191 |
+
* Detecta si una pregunta de mercado tiene correlacion financiera
|
| 192 |
+
* y devuelve los simbolos bursatiles relevantes.
|
| 193 |
+
*/
|
| 194 |
+
function extractFinancialSymbols(question) {
|
| 195 |
+
if (!question) return { hasFinancialCorrelation: false, symbols: [] };
|
| 196 |
+
|
| 197 |
+
const lower = question.toLowerCase();
|
| 198 |
+
const matchedSymbols = new Set();
|
| 199 |
+
const matchedKeywords = [];
|
| 200 |
+
|
| 201 |
+
for (const [keyword, symbols] of Object.entries(FINANCIAL_KEYWORDS)) {
|
| 202 |
+
// Match palabra completa o substring
|
| 203 |
+
const regex = new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b|${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i');
|
| 204 |
+
if (regex.test(lower)) {
|
| 205 |
+
symbols.forEach((s) => matchedSymbols.add(s));
|
| 206 |
+
matchedKeywords.push(keyword);
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
return {
|
| 211 |
+
hasFinancialCorrelation: matchedSymbols.size > 0,
|
| 212 |
+
symbols: Array.from(matchedSymbols),
|
| 213 |
+
keywords: matchedKeywords,
|
| 214 |
+
};
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
| 218 |
+
|
| 219 |
+
function normalizeText(text) {
|
| 220 |
+
if (!text) return '';
|
| 221 |
+
return text
|
| 222 |
+
.toLowerCase()
|
| 223 |
+
.replace(/[^\w\s]/g, ' ')
|
| 224 |
+
.replace(/\s+/g, ' ')
|
| 225 |
+
.trim();
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function deduplicateNews(news) {
|
| 229 |
+
const seen = new Set();
|
| 230 |
+
return news.filter((item) => {
|
| 231 |
+
const normalized = normalizeText(item.headline);
|
| 232 |
+
if (!normalized || seen.has(normalized)) return false;
|
| 233 |
+
seen.add(normalized);
|
| 234 |
+
return true;
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function shuffleArray(array) {
|
| 239 |
+
const arr = [...array];
|
| 240 |
+
for (let i = arr.length - 1; i > 0; i--) {
|
| 241 |
+
const j = Math.floor(Math.random() * (i + 1));
|
| 242 |
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
| 243 |
+
}
|
| 244 |
+
return arr;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function scoreRelevance(item, marketKeywords) {
|
| 248 |
+
if (!marketKeywords.length) return 0;
|
| 249 |
+
|
| 250 |
+
const textHeadline = normalizeText(item.headline);
|
| 251 |
+
const textSummary = normalizeText(item.summary);
|
| 252 |
+
let score = 0;
|
| 253 |
+
|
| 254 |
+
for (const kw of marketKeywords) {
|
| 255 |
+
if (textHeadline.includes(kw)) score += 3;
|
| 256 |
+
if (textSummary.includes(kw)) score += 1;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Bonus recencia
|
| 260 |
+
if (item.datetime) {
|
| 261 |
+
const ageHours = (Date.now() - new Date(item.datetime).getTime()) / (1000 * 60 * 60);
|
| 262 |
+
if (ageHours < 6) score += 2;
|
| 263 |
+
else if (ageHours < 24) score += 1;
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
return score;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function extractKeywords(question) {
|
| 270 |
+
if (!question) return [];
|
| 271 |
+
const stopWords = new Set([
|
| 272 |
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
| 273 |
+
'of', 'with', 'by', 'from', 'up', 'about', 'into', 'through', 'during',
|
| 274 |
+
'before', 'after', 'above', 'below', 'between', 'among', 'is', 'are',
|
| 275 |
+
'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does',
|
| 276 |
+
'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'shall',
|
| 277 |
+
'can', 'need', 'dare', 'ought', 'used', 'won', 'would', 'it', 'this',
|
| 278 |
+
'that', 'these', 'those', 'i', 'you', 'he', 'she', 'we', 'they', 'me',
|
| 279 |
+
'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our',
|
| 280 |
+
'their', 'what', 'which', 'who', 'whom', 'whose', 'where', 'when', 'why',
|
| 281 |
+
'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other',
|
| 282 |
+
'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than',
|
| 283 |
+
'too', 'very', 'just', 'now', 'then', 'here', 'there', 'once', 'again',
|
| 284 |
+
'further', 'once', 'also', 'back', 'still', 'yet', 'already', 'almost',
|
| 285 |
+
'quite', 'rather', 'really', 'soon', 'today', 'tomorrow', 'yesterday',
|
| 286 |
+
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'pero',
|
| 287 |
+
'en', 'de', 'a', 'por', 'para', 'con', 'sin', 'sobre', 'entre', 'durante',
|
| 288 |
+
'antes', 'despues', 'durante', 'es', 'son', 'era', 'fue', 'ser', 'estar',
|
| 289 |
+
'ha', 'han', 'habia', 'haya', 'hizo', 'hace', 'hacia', 'tiene', 'tienen',
|
| 290 |
+
'tuvo', 'tendria', 'puede', 'podria', 'debe', 'necesita', 'quiere', 'cada',
|
| 291 |
+
'algun', 'alguna', 'algunos', 'algunas', 'todo', 'toda', 'todos', 'todas',
|
| 292 |
+
'muy', 'mas', 'menos', 'mucho', 'muchos', 'poco', 'pocos', 'bastante',
|
| 293 |
+
'demasiado', 'casi', 'solo', 'solamente', 'tambien', 'aun', 'ya', 'todavia',
|
| 294 |
+
'siempre', 'nunca', 'jamás', 'ahora', 'hoy', 'manana', 'ayer', 'aqui', 'alli',
|
| 295 |
+
'alli', 'luego', 'entonces', 'asi', 'como', 'cuando', 'donde', 'porque',
|
| 296 |
+
'por que', 'quien', 'cual', 'cuales', 'cuanto', 'cuanta', 'cuantos', 'cuantas',
|
| 297 |
+
]);
|
| 298 |
+
|
| 299 |
+
return question
|
| 300 |
+
.toLowerCase()
|
| 301 |
+
.replace(/[^\w\s]/g, ' ')
|
| 302 |
+
.split(/\s+/)
|
| 303 |
+
.filter((w) => w.length > 2 && !stopWords.has(w));
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// ── Cache helpers ─────────────────────────────────────────────────────────────
|
| 307 |
+
|
| 308 |
+
function getCachedCompanyNews(symbol) {
|
| 309 |
+
const cached = companyNewsCache.get(symbol);
|
| 310 |
+
if (cached && Date.now() - cached.timestamp < NEWS_CACHE_TTL_MS) {
|
| 311 |
+
return cached.data;
|
| 312 |
+
}
|
| 313 |
+
return null;
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
function setCachedCompanyNews(symbol, data) {
|
| 317 |
+
companyNewsCache.set(symbol, { data, timestamp: Date.now() });
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function getCachedGeneralNews() {
|
| 321 |
+
if (generalNewsCache.data && Date.now() - generalNewsCache.timestamp < NEWS_CACHE_TTL_MS) {
|
| 322 |
+
return generalNewsCache.data;
|
| 323 |
+
}
|
| 324 |
+
return null;
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function setCachedGeneralNews(data) {
|
| 328 |
+
generalNewsCache.data = data;
|
| 329 |
+
generalNewsCache.timestamp = Date.now();
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// ── Public API ────────────────────────────────────────────────────────────────
|
| 333 |
+
|
| 334 |
+
/**
|
| 335 |
+
* Obtiene noticias financieras RELEVANTES para un mercado especifico.
|
| 336 |
+
*
|
| 337 |
+
* Estrategia:
|
| 338 |
+
* 1. Detecta si el mercado tiene correlacion financiera (bitcoin, crypto, etc.)
|
| 339 |
+
* 2. Si SI → busca company-news de los simbolos asociados (muy relevantes)
|
| 340 |
+
* 3. Si NO → devuelve array vacio (Qwen analizara solo el precio)
|
| 341 |
+
*
|
| 342 |
+
* @param {Object} market - Mercado de Polymarket ({ question })
|
| 343 |
+
* @returns {Promise<Object[]>} Noticias relevantes o array vacio.
|
| 344 |
+
*/
|
| 345 |
+
export async function fetchFinancialNewsForMarket(market) {
|
| 346 |
+
if (!market || !market.question) return [];
|
| 347 |
+
|
| 348 |
+
const { hasFinancialCorrelation, symbols, keywords } = extractFinancialSymbols(market.question);
|
| 349 |
+
|
| 350 |
+
// Si NO hay correlacion financiera directa, usamos noticias macro generales
|
| 351 |
+
// como contexto (mejor que dejar a la IA sin informacion alguna).
|
| 352 |
+
if (!hasFinancialCorrelation) {
|
| 353 |
+
logger.debug({ marketId: market.id, question: market.question }, 'No financial correlation, using general macro news as context');
|
| 354 |
+
return fetchGeneralMarketNews(15);
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
logger.info({ marketId: market.id, symbols, keywords }, 'Fetching company news for financial market');
|
| 358 |
+
|
| 359 |
+
// Obtener noticias de cada simbolo (con cache)
|
| 360 |
+
const allNews = [];
|
| 361 |
+
// Fechas dinamicas: alternar entre 1-3 dias para variedad
|
| 362 |
+
const daysBack = 1 + Math.floor(Math.random() * 2); // 1 o 2 dias
|
| 363 |
+
|
| 364 |
+
for (const symbol of symbols.slice(0, 5)) {
|
| 365 |
+
let news = getCachedCompanyNews(symbol);
|
| 366 |
+
if (!news) {
|
| 367 |
+
try {
|
| 368 |
+
news = await getCompanyNews(symbol, daysBack);
|
| 369 |
+
setCachedCompanyNews(symbol, news);
|
| 370 |
+
} catch (err) {
|
| 371 |
+
logger.warn({ err: err.message, symbol }, 'Failed to fetch company news');
|
| 372 |
+
news = [];
|
| 373 |
+
}
|
| 374 |
+
}
|
| 375 |
+
allNews.push(...news);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// Complementar con noticias generales del mercado para mas variedad
|
| 379 |
+
try {
|
| 380 |
+
const general = await fetchGeneralMarketNews(10);
|
| 381 |
+
allNews.push(...general);
|
| 382 |
+
} catch (e) { /* ignore */ }
|
| 383 |
+
|
| 384 |
+
// Deduplicar y ordenar por recencia
|
| 385 |
+
const deduped = deduplicateNews(allNews);
|
| 386 |
+
deduped.sort((a, b) => {
|
| 387 |
+
const da = a.datetime ? new Date(a.datetime).getTime() : 0;
|
| 388 |
+
const db = b.datetime ? new Date(b.datetime).getTime() : 0;
|
| 389 |
+
return db - da;
|
| 390 |
+
});
|
| 391 |
+
|
| 392 |
+
logger.info({ marketId: market.id, count: deduped.length, symbols }, 'Company news fetched');
|
| 393 |
+
return deduped;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
/**
|
| 397 |
+
* Obtiene noticias generales de mercado (macro).
|
| 398 |
+
* Rota entre multiples categorias para variedad.
|
| 399 |
+
*/
|
| 400 |
+
export async function fetchGeneralMarketNews(limit = 20) {
|
| 401 |
+
let news = getCachedGeneralNews();
|
| 402 |
+
if (!news) {
|
| 403 |
+
try {
|
| 404 |
+
// Rotar categorias para variedad cada vez
|
| 405 |
+
const categories = ['general', 'forex', 'crypto', 'merger'];
|
| 406 |
+
const shuffledCats = shuffleArray(categories);
|
| 407 |
+
|
| 408 |
+
const results = await Promise.all(
|
| 409 |
+
shuffledCats.map((cat) => getMarketNews(cat, 25))
|
| 410 |
+
);
|
| 411 |
+
|
| 412 |
+
news = deduplicateNews(results.flat());
|
| 413 |
+
news.sort((a, b) => {
|
| 414 |
+
const da = a.datetime ? new Date(a.datetime).getTime() : 0;
|
| 415 |
+
const db = b.datetime ? new Date(b.datetime).getTime() : 0;
|
| 416 |
+
return db - da;
|
| 417 |
+
});
|
| 418 |
+
setCachedGeneralNews(news);
|
| 419 |
+
} catch (err) {
|
| 420 |
+
logger.warn({ err: err.message }, 'Failed to fetch general market news');
|
| 421 |
+
return [];
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
// Devolver subconjunto aleatorio para variedad entre mercados
|
| 425 |
+
return shuffleArray(news).slice(0, limit);
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/**
|
| 429 |
+
* Filtra noticias por relevancia respecto a una pregunta de mercado.
|
| 430 |
+
*
|
| 431 |
+
* @param {Object[]} news - Array de noticias normalizadas.
|
| 432 |
+
* @param {string} question - Pregunta del mercado.
|
| 433 |
+
* @returns {Object[]} Noticias filtradas por relevancia (max MAX_NEWS_PER_MARKET).
|
| 434 |
+
*/
|
| 435 |
+
export function filterNewsByRelevance(news, question) {
|
| 436 |
+
if (!news.length || !question) return [];
|
| 437 |
+
|
| 438 |
+
const keywords = extractKeywords(question);
|
| 439 |
+
if (keywords.length === 0) {
|
| 440 |
+
return shuffleArray(news).slice(0, MAX_NEWS_PER_MARKET);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
const scored = news.map((item) => ({
|
| 444 |
+
item,
|
| 445 |
+
score: scoreRelevance(item, keywords),
|
| 446 |
+
}));
|
| 447 |
+
|
| 448 |
+
const relevant = scored
|
| 449 |
+
.filter((s) => s.score > 0)
|
| 450 |
+
.sort((a, b) => b.score - a.score)
|
| 451 |
+
.map((s) => s.item);
|
| 452 |
+
|
| 453 |
+
// Si hay muy pocas relevantes, complementar
|
| 454 |
+
if (relevant.length < 3) {
|
| 455 |
+
const usedIds = new Set(relevant.map((n) => n.id));
|
| 456 |
+
const extra = shuffleArray(news.filter((n) => !usedIds.has(n.id))).slice(0, MAX_NEWS_PER_MARKET - relevant.length);
|
| 457 |
+
return [...relevant, ...extra].slice(0, MAX_NEWS_PER_MARKET);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
return shuffleArray(relevant.slice(0, MAX_NEWS_PER_MARKET * 2)).slice(0, MAX_NEWS_PER_MARKET);
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
/**
|
| 464 |
+
* Invalida el cache de noticias.
|
| 465 |
+
*/
|
| 466 |
+
export function invalidateNewsCache() {
|
| 467 |
+
companyNewsCache.clear();
|
| 468 |
+
generalNewsCache.data = null;
|
| 469 |
+
generalNewsCache.timestamp = 0;
|
| 470 |
+
logger.info('News cache invalidated');
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
/**
|
| 474 |
+
* Legacy: obtiene noticias financieras generales (sin correlacion por mercado).
|
| 475 |
+
* @deprecated Usar fetchFinancialNewsForMarket(market) en su lugar.
|
| 476 |
+
*/
|
| 477 |
+
export async function fetchFinancialNews() {
|
| 478 |
+
return fetchGeneralMarketNews(30);
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
/**
|
| 482 |
+
* Obtiene titulares financieros para multiples simbolos.
|
| 483 |
+
* @deprecated Usar fetchFinancialNewsForMarket(market) en su lugar.
|
| 484 |
+
*/
|
| 485 |
+
export async function fetchHeadlinesForPipeline({
|
| 486 |
+
symbols = [],
|
| 487 |
+
daysBack = 7,
|
| 488 |
+
limitPerSymbol = 20,
|
| 489 |
+
} = {}) {
|
| 490 |
+
if (!symbols.length) return [];
|
| 491 |
+
|
| 492 |
+
const allNews = [];
|
| 493 |
+
for (const symbol of symbols.slice(0, 5)) {
|
| 494 |
+
try {
|
| 495 |
+
const items = await getCompanyNews(symbol, daysBack);
|
| 496 |
+
allNews.push(...items.slice(0, limitPerSymbol).map((it) => ({ symbol, ...it })));
|
| 497 |
+
} catch (err) {
|
| 498 |
+
logger.warn({ err: err.message, symbol }, 'getCompanyNews failed for symbol');
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
return allNews;
|
| 502 |
+
}
|
backend/src/markets/markets.controller.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Controladores del modulo de mercados.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - list(req, res) → listado paginado y filtrado de mercados activos.
|
| 6 |
+
* - getById(req, res) → detalle de un mercado por su ID nativo de Polymarket.
|
| 7 |
+
*
|
| 8 |
+
* Datos expuestos:
|
| 9 |
+
* - id, question, category, countryCode, yesPrice, noPrice,
|
| 10 |
+
* volumeEur, liquidityEur, status, closesAt, lastSynced.
|
| 11 |
+
*
|
| 12 |
+
* Errores:
|
| 13 |
+
* - 404 NOT_FOUND si el mercado no existe.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { ok } from '../utils/apiResponse.js';
|
| 17 |
+
import { marketsService } from './markets.service.js';
|
| 18 |
+
|
| 19 |
+
export const marketsController = {
|
| 20 |
+
async list(req, res) {
|
| 21 |
+
const { limit, offset, category, status } = req.query;
|
| 22 |
+
const { data, total } = await marketsService.list({ limit, offset, category, status });
|
| 23 |
+
ok(res, data, { total, limit, offset });
|
| 24 |
+
},
|
| 25 |
+
|
| 26 |
+
async getById(req, res) {
|
| 27 |
+
const market = await marketsService.getById(req.params.id);
|
| 28 |
+
ok(res, market);
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
async getPriceHistory(req, res) {
|
| 32 |
+
const { interval } = req.query;
|
| 33 |
+
const history = await marketsService.getPriceHistory(req.params.id, interval);
|
| 34 |
+
ok(res, history);
|
| 35 |
+
},
|
| 36 |
+
};
|
backend/src/markets/markets.repository.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Repositorio de acceso a datos para el modelo Market.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - findMany({ limit, offset, category, status }) → listado paginado y filtrado.
|
| 6 |
+
* - count({ category, status }) → conteo para paginacion.
|
| 7 |
+
* - findById(id) → busqueda por ID nativo.
|
| 8 |
+
* - upsert(market) → crea o actualiza un mercado.
|
| 9 |
+
*
|
| 10 |
+
* Todas las operaciones usan Prisma ORM (queries parametrizadas, sin SQL injection).
|
| 11 |
+
* El orden por defecto es descendente por volumen (volumeEur).
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { prisma } from '../utils/prisma.js';
|
| 15 |
+
|
| 16 |
+
export const marketsRepository = {
|
| 17 |
+
findMany({ limit, offset, category, status }) {
|
| 18 |
+
const where = { status };
|
| 19 |
+
if (category) where.category = category;
|
| 20 |
+
// Ordenamos por liquidez (lo realmente tradeable AHORA) y luego volumen
|
| 21 |
+
// como desempate. Esto evita que los whales politicos historicos copen
|
| 22 |
+
// siempre la primera pagina.
|
| 23 |
+
return prisma.market.findMany({
|
| 24 |
+
where,
|
| 25 |
+
orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
|
| 26 |
+
take: limit,
|
| 27 |
+
skip: offset,
|
| 28 |
+
});
|
| 29 |
+
},
|
| 30 |
+
|
| 31 |
+
count({ category, status }) {
|
| 32 |
+
const where = { status };
|
| 33 |
+
if (category) where.category = category;
|
| 34 |
+
return prisma.market.count({ where });
|
| 35 |
+
},
|
| 36 |
+
|
| 37 |
+
findById(id) {
|
| 38 |
+
return prisma.market.findUnique({ where: { id } });
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
upsert(market) {
|
| 42 |
+
return prisma.market.upsert({
|
| 43 |
+
where: { id: market.id },
|
| 44 |
+
update: market,
|
| 45 |
+
create: market,
|
| 46 |
+
});
|
| 47 |
+
},
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* Marca como 'closed' los mercados activos que NO esten en la lista de IDs.
|
| 51 |
+
* Se invoca despues de un sync para purgar mercados que ya no aparecen
|
| 52 |
+
* en el fetch curado por tags (evita mostrar restos de syncs anteriores).
|
| 53 |
+
*
|
| 54 |
+
* @param {string[]} activeIds - IDs presentes en el ultimo sync.
|
| 55 |
+
* @returns {Promise<number>} Numero de mercados marcados como cerrados.
|
| 56 |
+
*/
|
| 57 |
+
async deactivateStale(activeIds) {
|
| 58 |
+
const result = await prisma.market.updateMany({
|
| 59 |
+
where: {
|
| 60 |
+
id: { notIn: activeIds },
|
| 61 |
+
status: 'active',
|
| 62 |
+
},
|
| 63 |
+
data: { status: 'closed' },
|
| 64 |
+
});
|
| 65 |
+
return result.count;
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Selecciona un conjunto diversificado de mercados activos, ponderado por
|
| 70 |
+
* liquidez+volumen y distribuido entre categorias de alto valor accionable.
|
| 71 |
+
*
|
| 72 |
+
* Categorias prioritarias (peso = numero de mercados pedidos):
|
| 73 |
+
* - cripto, economía, geopolítica → mayor peso (mas alpha financiero)
|
| 74 |
+
* - política, ciencia → peso medio
|
| 75 |
+
* - entretenimiento, deportes, general → peso bajo (relleno si sobra)
|
| 76 |
+
*
|
| 77 |
+
* @param {number} total - Numero total deseado (default 40).
|
| 78 |
+
* @returns {Promise<Market[]>}
|
| 79 |
+
*/
|
| 80 |
+
async findDiversified(total = 40) {
|
| 81 |
+
const weights = {
|
| 82 |
+
'cripto': 0.20,
|
| 83 |
+
'economía': 0.18,
|
| 84 |
+
'geopolítica': 0.18,
|
| 85 |
+
'política': 0.14,
|
| 86 |
+
'ciencia': 0.12,
|
| 87 |
+
'entretenimiento': 0.08,
|
| 88 |
+
'deportes': 0.05,
|
| 89 |
+
'general': 0.05,
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const slices = await Promise.all(
|
| 93 |
+
Object.entries(weights).map(async ([category, weight]) => {
|
| 94 |
+
const take = Math.max(1, Math.round(total * weight));
|
| 95 |
+
// Score: ordenamos por liquidez DESC para priorizar mercados tradeables
|
| 96 |
+
return prisma.market.findMany({
|
| 97 |
+
where: { status: 'active', category },
|
| 98 |
+
orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
|
| 99 |
+
take,
|
| 100 |
+
});
|
| 101 |
+
}),
|
| 102 |
+
);
|
| 103 |
+
|
| 104 |
+
const picked = slices.flat();
|
| 105 |
+
const seen = new Set(picked.map((m) => m.id));
|
| 106 |
+
|
| 107 |
+
// Si no llegamos al total (categoria vacia), rellenamos con los mas liquidos restantes
|
| 108 |
+
if (picked.length < total) {
|
| 109 |
+
const remaining = total - picked.length;
|
| 110 |
+
const filler = await prisma.market.findMany({
|
| 111 |
+
where: { status: 'active', id: { notIn: Array.from(seen) } },
|
| 112 |
+
orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
|
| 113 |
+
take: remaining,
|
| 114 |
+
});
|
| 115 |
+
picked.push(...filler);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return picked.slice(0, total);
|
| 119 |
+
},
|
| 120 |
+
};
|
backend/src/markets/markets.routes.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Rutas REST del modulo de mercados.
|
| 3 |
+
*
|
| 4 |
+
* Endpoints (montados en /api/v1/markets):
|
| 5 |
+
* GET / → listado paginado/filtrado (validate listQuery en query).
|
| 6 |
+
* GET /:id → detalle de mercado (validate idParam en params).
|
| 7 |
+
*
|
| 8 |
+
* No requieren autenticacion (datos publicos de Polymarket).
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { Router } from 'express';
|
| 12 |
+
import { validate } from '../middlewares/validate.js';
|
| 13 |
+
import { marketsController } from './markets.controller.js';
|
| 14 |
+
import { listQuery, idParam } from './markets.validators.js';
|
| 15 |
+
|
| 16 |
+
const router = Router();
|
| 17 |
+
|
| 18 |
+
router.get('/', validate(listQuery, 'query'), marketsController.list);
|
| 19 |
+
router.get('/:id/history', validate(idParam, 'params'), marketsController.getPriceHistory);
|
| 20 |
+
router.get('/:id', validate(idParam, 'params'), marketsController.getById);
|
| 21 |
+
|
| 22 |
+
export default router;
|
backend/src/markets/markets.service.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Logica de negocio del modulo de mercados.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - list(query) → delega a marketsRepository.findMany + count para paginacion.
|
| 6 |
+
* - getById(id) → busca un mercado; si no existe lanza 404.
|
| 7 |
+
*
|
| 8 |
+
* Consumido por:
|
| 9 |
+
* - markets.controller.js (API REST).
|
| 10 |
+
* - scheduler.js (syncMarkets cada 30s).
|
| 11 |
+
*
|
| 12 |
+
* No contiene logica de scraping directa; delega a polymarket.client.js
|
| 13 |
+
* y persiste via marketsRepository.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 17 |
+
import { marketsRepository } from './markets.repository.js';
|
| 18 |
+
import { httpGet } from '../utils/httpClient.js';
|
| 19 |
+
|
| 20 |
+
const CLOB_BASE = 'https://clob.polymarket.com';
|
| 21 |
+
|
| 22 |
+
export const marketsService = {
|
| 23 |
+
async list(query) {
|
| 24 |
+
// Si no hay filtro de categoria y es la primera pagina, devolvemos un mix
|
| 25 |
+
// diversificado entre categorias (mejor UX que la pagina mono-categoria
|
| 26 |
+
// que sale al ordenar por liquidez DESC).
|
| 27 |
+
if (!query.category && query.offset === 0 && query.status === 'active') {
|
| 28 |
+
const diversified = await marketsRepository.findDiversified(query.limit);
|
| 29 |
+
const total = await marketsRepository.count(query);
|
| 30 |
+
return { data: diversified, total };
|
| 31 |
+
}
|
| 32 |
+
const [data, total] = await Promise.all([
|
| 33 |
+
marketsRepository.findMany(query),
|
| 34 |
+
marketsRepository.count(query),
|
| 35 |
+
]);
|
| 36 |
+
return { data, total };
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
async getById(id) {
|
| 40 |
+
const market = await marketsRepository.findById(id);
|
| 41 |
+
if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
|
| 42 |
+
return market;
|
| 43 |
+
},
|
| 44 |
+
|
| 45 |
+
async getPriceHistory(id, interval = '1w') {
|
| 46 |
+
const market = await marketsRepository.findById(id);
|
| 47 |
+
if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
|
| 48 |
+
if (!market.clobTokenId) throw new HttpError(404, 'NO_CLOB_TOKEN', 'Price history not available for this market');
|
| 49 |
+
|
| 50 |
+
const fidelity = interval === '1d' ? 60 : 1440;
|
| 51 |
+
const url = `${CLOB_BASE}/prices-history?market=${market.clobTokenId}&interval=${interval}&fidelity=${fidelity}`;
|
| 52 |
+
const data = await httpGet(url);
|
| 53 |
+
return data.history ?? [];
|
| 54 |
+
},
|
| 55 |
+
};
|
backend/src/markets/markets.validators.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Esquemas Zod para validar inputs del modulo de mercados.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - listQuery → limit (1-100, default 20), offset, category enum, status enum.
|
| 6 |
+
* - idParam → string no vacio para el parametro :id.
|
| 7 |
+
*
|
| 8 |
+
* Consumido por:
|
| 9 |
+
* - markets.routes.js → validate(listQuery, 'query') y validate(idParam, 'params').
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { z } from 'zod';
|
| 13 |
+
|
| 14 |
+
export const listQuery = z.object({
|
| 15 |
+
limit: z.coerce.number().int().min(1).max(200).default(60),
|
| 16 |
+
offset: z.coerce.number().int().min(0).default(0),
|
| 17 |
+
// Acepta cualquier categoria (las del DB estan en espanol y son dinamicas).
|
| 18 |
+
category: z.string().optional(),
|
| 19 |
+
status: z.enum(['active', 'closed', 'resolved']).default('active'),
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
export const idParam = z.object({
|
| 23 |
+
id: z.string().min(1),
|
| 24 |
+
});
|
backend/src/markets/polymarket.client.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente de integracion con Polymarket Gamma API.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - fetchActiveMarkets(limit) → obtiene mercados activos desde gamma-api.polymarket.com.
|
| 6 |
+
* - mapMarket(raw) → normaliza campos crudos a la estructura del modelo Market:
|
| 7 |
+
* * id, question, category, countryCode
|
| 8 |
+
* * yesPrice / noPrice (parseados desde outcomePrices JSON)
|
| 9 |
+
* * volumeEur / liquidityEur (convertidos de USD a EUR con tasa 0.93)
|
| 10 |
+
* * status (active | closed | resolved) derivado de flags active/closed/archived
|
| 11 |
+
* * closesAt, lastSynced
|
| 12 |
+
*
|
| 13 |
+
* No requiere autenticacion. No usa CLOB API (solo datos publicos de mercado).
|
| 14 |
+
* Consumido por scheduler.js cada 30 segundos.
|
| 15 |
+
*/
|
| 16 |
+
|
| 17 |
+
import { httpGet } from '../utils/httpClient.js';
|
| 18 |
+
import { logger } from '../utils/logger.js';
|
| 19 |
+
|
| 20 |
+
const GAMMA_MARKETS_URL = 'https://gamma-api.polymarket.com/markets';
|
| 21 |
+
const GAMMA_EVENTS_URL = 'https://gamma-api.polymarket.com/events';
|
| 22 |
+
const USD_TO_EUR = 0.93;
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Tags de Polymarket que usamos para fetching diversificado.
|
| 26 |
+
* El endpoint /markets IGNORA tag_id, pero /events lo respeta correctamente.
|
| 27 |
+
* Cada tag aporta N mercados al pool global - el weight controla cuantos.
|
| 28 |
+
*
|
| 29 |
+
* Tags verificados manualmente desde gamma-api.polymarket.com/tags.
|
| 30 |
+
*/
|
| 31 |
+
const TAG_SLICES = [
|
| 32 |
+
// Financieros directos (mayor peso - mas alpha)
|
| 33 |
+
{ tagId: 1312, slug: 'crypto-prices', category: 'cripto', limit: 25 },
|
| 34 |
+
{ tagId: 602, slug: 'stock-market', category: 'economía', limit: 25 },
|
| 35 |
+
{ tagId: 159, slug: 'fed', category: 'economía', limit: 15 },
|
| 36 |
+
{ tagId: 992, slug: 'labor-market', category: 'economía', limit: 10 },
|
| 37 |
+
{ tagId: 1562, slug: 'market-caps', category: 'economía', limit: 10 },
|
| 38 |
+
|
| 39 |
+
// Tech / AI (alta accionabilidad)
|
| 40 |
+
{ tagId: 1401, slug: 'tech', category: 'ciencia', limit: 20 },
|
| 41 |
+
{ tagId: 22, slug: 'technology', category: 'ciencia', limit: 15 },
|
| 42 |
+
{ tagId: 101999, slug: 'big-tech', category: 'ciencia', limit: 15 },
|
| 43 |
+
{ tagId: 537, slug: 'openai', category: 'ciencia', limit: 10 },
|
| 44 |
+
|
| 45 |
+
// Geopolitica (afecta oil/gold/defense)
|
| 46 |
+
{ tagId: 154, slug: 'middle-east', category: 'geopolítica', limit: 20 },
|
| 47 |
+
{ tagId: 78, slug: 'iran', category: 'geopolítica', limit: 10 },
|
| 48 |
+
{ tagId: 180, slug: 'israel', category: 'geopolítica', limit: 10 },
|
| 49 |
+
{ tagId: 114, slug: 'syria', category: 'geopolítica', limit: 10 },
|
| 50 |
+
{ tagId: 172, slug: 'oil-industry', category: 'economía', limit: 15 },
|
| 51 |
+
{ tagId: 248, slug: 'energy-industry', category: 'economía', limit: 10 },
|
| 52 |
+
|
| 53 |
+
// Regional coverage (variedad geografica)
|
| 54 |
+
{ tagId: 100410, slug: 'europe', category: 'geopolítica', limit: 20 },
|
| 55 |
+
{ tagId: 167, slug: 'argentina', category: 'geopolítica', limit: 10 },
|
| 56 |
+
{ tagId: 872, slug: 'pakistan', category: 'geopolítica', limit: 10 },
|
| 57 |
+
{ tagId: 525, slug: 'netherlands', category: 'geopolítica', limit: 5 },
|
| 58 |
+
{ tagId: 258, slug: 'taiwan-election', category: 'geopolítica', limit: 8 },
|
| 59 |
+
{ tagId: 104846, slug: 'uk-elections', category: 'política', limit: 8 },
|
| 60 |
+
{ tagId: 103388, slug: 'thailand-election', category: 'geopolítica', limit: 5 },
|
| 61 |
+
{ tagId: 104090, slug: 'french-mayoral', category: 'política', limit: 5 },
|
| 62 |
+
{ tagId: 104968, slug: 'mexico-election', category: 'política', limit: 8 },
|
| 63 |
+
{ tagId: 103219, slug: 'bolivia-election', category: 'política', limit: 5 },
|
| 64 |
+
|
| 65 |
+
// Politica (peso moderado - es donde hay mas volumen)
|
| 66 |
+
{ tagId: 2, slug: 'politics', category: 'política', limit: 15 },
|
| 67 |
+
{ tagId: 789, slug: 'us-politics', category: 'política', limit: 10 },
|
| 68 |
+
{ tagId: 126, slug: 'trump', category: 'política', limit: 10 },
|
| 69 |
+
|
| 70 |
+
// Corporativo / clima / cultura
|
| 71 |
+
{ tagId: 550, slug: 'corporate-news', category: 'economía', limit: 15 },
|
| 72 |
+
{ tagId: 102890, slug: 'climate-change', category: 'ciencia', limit: 10 },
|
| 73 |
+
{ tagId: 596, slug: 'pop-culture', category: 'entretenimiento', limit: 10 },
|
| 74 |
+
{ tagId: 100451, slug: 'breaking', category: 'general', limit: 15 },
|
| 75 |
+
];
|
| 76 |
+
|
| 77 |
+
function mapStatus({ closed, archived }) {
|
| 78 |
+
if (archived) return 'resolved';
|
| 79 |
+
if (closed) return 'closed';
|
| 80 |
+
return 'active';
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function parsePrices(outcomePrices) {
|
| 84 |
+
try {
|
| 85 |
+
const arr = JSON.parse(outcomePrices);
|
| 86 |
+
return {
|
| 87 |
+
yesPrice: arr[0] != null ? parseFloat(arr[0]) : null,
|
| 88 |
+
noPrice: arr[1] != null ? parseFloat(arr[1]) : null,
|
| 89 |
+
};
|
| 90 |
+
} catch {
|
| 91 |
+
return { yesPrice: null, noPrice: null };
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function inferCategory(question, eventTitle = '') {
|
| 96 |
+
const text = `${question} ${eventTitle}`.toLowerCase();
|
| 97 |
+
|
| 98 |
+
const rules = [
|
| 99 |
+
{ keywords: ['bitcoin', 'btc', 'ethereum', 'eth', 'crypto', 'blockchain', 'solana', 'cardano', 'altcoin', 'defi', 'nft'], category: 'cripto' },
|
| 100 |
+
{ keywords: ['fed', 'ecb', 'rate', 'interest', 'inflation', 'gdp', 'recession', 'economy', 'tariff', 'trade', 'dollar', 'euro', 'yen', 'bank', 'finance', 'stock market', 'sp500', 'nasdaq', 'unemployment', 'cpi', 'ppi'], category: 'economía' },
|
| 101 |
+
{ keywords: ['trump', 'biden', 'election', 'president', 'democrat', 'republican', 'congress', 'senate', 'house', 'vote', 'impeach', 'nominee', 'primary', 'governor', 'mayor', 'political', 'politics', 'campaign'], category: 'política' },
|
| 102 |
+
{ keywords: ['war', 'ukraine', 'russia', 'putin', 'china', 'xi', 'iran', 'israel', 'gaza', 'north korea', 'taiwan', 'invasion', 'missile', 'nuclear', 'sanctions', 'diplomatic', 'embassy', 'conflict'], category: 'geopolítica' },
|
| 103 |
+
{ keywords: ['super bowl', 'world cup', 'olympics', 'nba', 'nfl', 'mlb', 'soccer', 'football', 'tennis', 'golf', 'mvp', 'championship', 'fifa', 'uefa', 'premier league', 'playoff'], category: 'deportes' },
|
| 104 |
+
{ keywords: ['album', 'movie', 'oscar', 'grammy', 'emmy', 'hollywood', 'actor', 'singer', 'celebrity', 'gta', 'video game', 'song', 'chart', 'streaming', 'netflix', 'disney', 'marvel', 'rockstar'], category: 'entretenimiento' },
|
| 105 |
+
{ keywords: ['ai', 'spacex', 'mars', 'rocket', 'vaccine', 'climate', 'covid', 'pandemic', 'tesla', 'elon', 'neuralink', 'fusion', 'crispr'], category: 'ciencia' },
|
| 106 |
+
];
|
| 107 |
+
|
| 108 |
+
for (const rule of rules) {
|
| 109 |
+
if (rule.keywords.some((kw) => text.includes(kw))) {
|
| 110 |
+
return rule.category;
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return 'general';
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function inferCountryCode(question, eventTitle = '') {
|
| 118 |
+
// Envolver con espacios para evitar coincidencias parciales de substrings
|
| 119 |
+
const text = ` ${question} ${eventTitle} `.toLowerCase();
|
| 120 |
+
|
| 121 |
+
const rules = [
|
| 122 |
+
// Estados Unidos
|
| 123 |
+
{ keywords: [' usa ', ' us ', ' america ', ' american ', ' trump ', ' biden ', ' clinton ', ' obama ', ' harris ', ' pence ', ' sanders ', ' warren ', ' mcconnell ', ' pelosi ', ' schumer ', ' congress ', ' senate ', ' house of representatives ', ' fed ', ' super bowl ', ' nba ', ' nfl ', ' mlb ', ' nasdaq ', ' sp500 ', ' hollywood ', ' california ', ' new york ', ' texas ', ' florida ', ' white house ', ' pentagon ', ' supreme court ', ' capitol ', ' governor of ', ' democratic nomination ', ' republican nomination ', ' presidential '], code: 'US' },
|
| 124 |
+
// Reino Unido
|
| 125 |
+
{ keywords: [' uk ', ' britain ', ' british ', ' england ', ' london ', ' brexit ', ' boe ', ' pound ', ' sterling ', ' scotland ', ' wales ', ' king charles ', ' prime minister ', ' parliament ', ' westminster ', ' tory ', ' labour party '], code: 'GB' },
|
| 126 |
+
// Alemania
|
| 127 |
+
{ keywords: [' germany ', ' german ', ' merkel ', ' scholz ', ' berlin ', ' bundestag ', ' deutsche '], code: 'DE' },
|
| 128 |
+
// Francia
|
| 129 |
+
{ keywords: [' france ', ' french ', ' macron ', ' paris ', ' le pen ', ' élysée '], code: 'FR' },
|
| 130 |
+
// Italia
|
| 131 |
+
{ keywords: [' italy ', ' italian ', ' meloni ', ' rome ', ' berlusconi '], code: 'IT' },
|
| 132 |
+
// España
|
| 133 |
+
{ keywords: [' spain ', ' spanish ', ' sánchez ', ' madrid ', ' catalonia ', ' catalan '], code: 'ES' },
|
| 134 |
+
// China
|
| 135 |
+
{ keywords: [' china ', ' chinese ', ' xi jinping ', ' beijing ', ' shanghai ', ' hong kong ', ' taiwan ', ' yuan ', ' alibaba ', ' byd '], code: 'CN' },
|
| 136 |
+
// Rusia
|
| 137 |
+
{ keywords: [' russia ', ' russian ', ' putin ', ' moscow ', ' kremlin ', ' ruble '], code: 'RU' },
|
| 138 |
+
// India
|
| 139 |
+
{ keywords: [' india ', ' indian ', ' modi ', ' mumbai ', ' delhi ', ' rupee ', ' bjp '], code: 'IN' },
|
| 140 |
+
// Brasil
|
| 141 |
+
{ keywords: [' brazil ', ' brazilian ', ' brasil ', ' lula ', ' bolsonaro ', ' real '], code: 'BR' },
|
| 142 |
+
// Japón
|
| 143 |
+
{ keywords: [' japan ', ' japanese ', ' tokyo ', ' boj ', ' yen ', ' nikkei ', ' suzuki '], code: 'JP' },
|
| 144 |
+
// Canadá
|
| 145 |
+
{ keywords: [' canada ', ' canadian ', ' trudeau ', ' toronto ', ' loonie '], code: 'CA' },
|
| 146 |
+
// Ucrania
|
| 147 |
+
{ keywords: [' ukraine ', ' ukrainian ', ' kyiv ', ' zelensky '], code: 'UA' },
|
| 148 |
+
// Israel
|
| 149 |
+
{ keywords: [' israel ', ' israeli ', ' gaza ', ' netanyahu ', ' palestine ', ' palestinian ', ' hamas '], code: 'IL' },
|
| 150 |
+
// Irán
|
| 151 |
+
{ keywords: [' iran ', ' iranian ', ' tehran ', ' ayatollah '], code: 'IR' },
|
| 152 |
+
// Corea
|
| 153 |
+
{ keywords: [' korea ', ' north korea ', ' south korea ', ' korean ', ' seoul ', ' kim jong '], code: 'KR' },
|
| 154 |
+
// Australia
|
| 155 |
+
{ keywords: [' australia ', ' australian ', ' sydney ', ' rba ', ' aud '], code: 'AU' },
|
| 156 |
+
// México
|
| 157 |
+
{ keywords: [' mexico ', ' mexican ', ' peso ', ' amlo ', ' mexican president '], code: 'MX' },
|
| 158 |
+
// Turquía
|
| 159 |
+
{ keywords: [' turkey ', ' turkish ', ' erdogan ', ' lira ', ' istanbul '], code: 'TR' },
|
| 160 |
+
// Arabia Saudita
|
| 161 |
+
{ keywords: [' saudi ', ' saudi arabia ', ' riyadh ', ' aramco '], code: 'SA' },
|
| 162 |
+
// Sudáfrica
|
| 163 |
+
{ keywords: [' south africa ', ' south african ', ' johannesburg ', ' rand '], code: 'ZA' },
|
| 164 |
+
// Argentina
|
| 165 |
+
{ keywords: [' argentina ', ' argentinian ', ' milei ', ' buenos aires ', ' peso argentino '], code: 'AR' },
|
| 166 |
+
// Uzbekistán
|
| 167 |
+
{ keywords: [' uzbekistan ', ' uzbek '], code: 'UZ' },
|
| 168 |
+
// Nueva Zelanda
|
| 169 |
+
{ keywords: [' new zealand ', ' kiwi '], code: 'NZ' },
|
| 170 |
+
// Países Bajos
|
| 171 |
+
{ keywords: [' netherlands ', ' dutch ', ' amsterdam ', ' rutte '], code: 'NL' },
|
| 172 |
+
// Polonia
|
| 173 |
+
{ keywords: [' poland ', ' polish ', ' warsaw ', ' duda '], code: 'PL' },
|
| 174 |
+
// Suiza
|
| 175 |
+
{ keywords: [' switzerland ', ' swiss ', ' zurich ', ' geneva ', ' franc '], code: 'CH' },
|
| 176 |
+
// Suecia
|
| 177 |
+
{ keywords: [' sweden ', ' swedish ', ' stockholm '], code: 'SE' },
|
| 178 |
+
// Noruega
|
| 179 |
+
{ keywords: [' norway ', ' norwegian ', ' oslo '], code: 'NO' },
|
| 180 |
+
// Dinamarca
|
| 181 |
+
{ keywords: [' denmark ', ' danish ', ' copenhagen '], code: 'DK' },
|
| 182 |
+
// Finlandia
|
| 183 |
+
{ keywords: [' finland ', ' finnish ', ' helsinki '], code: 'FI' },
|
| 184 |
+
// Grecia
|
| 185 |
+
{ keywords: [' greece ', ' greek ', ' athens '], code: 'GR' },
|
| 186 |
+
// Portugal
|
| 187 |
+
{ keywords: [' portugal ', ' portuguese ', ' lisbon '], code: 'PT' },
|
| 188 |
+
// Bélgica
|
| 189 |
+
{ keywords: [' belgium ', ' belgian ', ' brussels '], code: 'BE' },
|
| 190 |
+
// Austria
|
| 191 |
+
{ keywords: [' austria ', ' austrian ', ' vienna '], code: 'AT' },
|
| 192 |
+
// Irlanda
|
| 193 |
+
{ keywords: [' ireland ', ' irish ', ' dublin '], code: 'IE' },
|
| 194 |
+
// Pakistán
|
| 195 |
+
{ keywords: [' pakistan ', ' pakistani ', ' islamabad '], code: 'PK' },
|
| 196 |
+
// Bangladés
|
| 197 |
+
{ keywords: [' bangladesh ', ' bangladeshi ', ' dhaka '], code: 'BD' },
|
| 198 |
+
// Indonesia
|
| 199 |
+
{ keywords: [' indonesia ', ' indonesian ', ' jakarta '], code: 'ID' },
|
| 200 |
+
// Filipinas
|
| 201 |
+
{ keywords: [' philippines ', ' filipino ', ' manila '], code: 'PH' },
|
| 202 |
+
// Vietnam
|
| 203 |
+
{ keywords: [' vietnam ', ' vietnamese ', ' hanoi '], code: 'VN' },
|
| 204 |
+
// Tailandia
|
| 205 |
+
{ keywords: [' thailand ', ' thai ', ' bangkok '], code: 'TH' },
|
| 206 |
+
// Malasia
|
| 207 |
+
{ keywords: [' malaysia ', ' malaysian ', ' kuala lumpur '], code: 'MY' },
|
| 208 |
+
// Singapur
|
| 209 |
+
{ keywords: [' singapore ', ' singaporean '], code: 'SG' },
|
| 210 |
+
// Colombia
|
| 211 |
+
{ keywords: [' colombia ', ' colombian ', ' bogotá '], code: 'CO' },
|
| 212 |
+
// Chile
|
| 213 |
+
{ keywords: [' chile ', ' chilean ', ' santiago '], code: 'CL' },
|
| 214 |
+
// Perú
|
| 215 |
+
{ keywords: [' peru ', ' peruvian ', ' lima '], code: 'PE' },
|
| 216 |
+
// Venezuela
|
| 217 |
+
{ keywords: [' venezuela ', ' venezuelan ', ' caracas ', ' maduro '], code: 'VE' },
|
| 218 |
+
// Ecuador
|
| 219 |
+
{ keywords: [' ecuador ', ' ecuadorian ', ' quito '], code: 'EC' },
|
| 220 |
+
// Nigeria
|
| 221 |
+
{ keywords: [' nigeria ', ' nigerian ', ' lagos ', ' abuja '], code: 'NG' },
|
| 222 |
+
// Egipto
|
| 223 |
+
{ keywords: [' egypt ', ' egyptian ', ' cairo '], code: 'EG' },
|
| 224 |
+
// Etiopía
|
| 225 |
+
{ keywords: [' ethiopia ', ' ethiopian ', ' addis ababa '], code: 'ET' },
|
| 226 |
+
// Kenia
|
| 227 |
+
{ keywords: [' kenya ', ' kenyan ', ' nairobi '], code: 'KE' },
|
| 228 |
+
// Cuba
|
| 229 |
+
{ keywords: [' cuba ', ' cuban ', ' havana '], code: 'CU' },
|
| 230 |
+
// República Dominicana
|
| 231 |
+
{ keywords: [' dominican republic ', ' dominican '], code: 'DO' },
|
| 232 |
+
// Curazao
|
| 233 |
+
{ keywords: [' curaçao ', ' curacao '], code: 'CW' },
|
| 234 |
+
];
|
| 235 |
+
|
| 236 |
+
for (const rule of rules) {
|
| 237 |
+
if (rule.keywords.some((kw) => text.includes(kw))) {
|
| 238 |
+
return rule.code;
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
return null;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/**
|
| 246 |
+
* Determina si una pregunta de mercado es analizable por la IA con edge plausible.
|
| 247 |
+
*
|
| 248 |
+
* Excluimos:
|
| 249 |
+
* - "Will X say WORD by date" (sin base de datos predictiva)
|
| 250 |
+
* - "Mentions" / "first to say" markets
|
| 251 |
+
* - Mercados de views/views-counts de YouTubers
|
| 252 |
+
* - "Before GTA VI" type meme markets
|
| 253 |
+
*
|
| 254 |
+
* Mantenemos:
|
| 255 |
+
* - Precios objetivo (Bitcoin $X by Y)
|
| 256 |
+
* - Decisiones Fed/ECB/BOE
|
| 257 |
+
* - Eventos geopoliticos concretos (acuerdos, sanciones, elecciones)
|
| 258 |
+
* - Cifras macro (CPI, GDP, employment)
|
| 259 |
+
*/
|
| 260 |
+
function isAnalyzable(question, category) {
|
| 261 |
+
const q = question.toLowerCase();
|
| 262 |
+
|
| 263 |
+
// Patrones de mercados NO analizables (memes, predicciones-de-palabras, views)
|
| 264 |
+
const blacklist = [
|
| 265 |
+
/\bsay\b.*\?$/i, // "Will Trump say X?"
|
| 266 |
+
/how many.*tweet/i, // tweets count
|
| 267 |
+
/\bmentions?\b/i,
|
| 268 |
+
/\b# of tweets\b/i,
|
| 269 |
+
/views? on day/i, // MrBeast views
|
| 270 |
+
/views? in /i,
|
| 271 |
+
/before gta/i, // GTA VI memes
|
| 272 |
+
/jesus christ return/i,
|
| 273 |
+
/alien/i,
|
| 274 |
+
/\bpoll(ed|ing) (above|below)/i, // detailed polling minutiae
|
| 275 |
+
/first to/i, // "first to reach X"
|
| 276 |
+
/wear (a|the)/i, // clothing predictions
|
| 277 |
+
/shave/i,
|
| 278 |
+
/grammy|oscar|emmy/i, // award shows (subjective)
|
| 279 |
+
];
|
| 280 |
+
|
| 281 |
+
if (blacklist.some((re) => re.test(q))) return false;
|
| 282 |
+
|
| 283 |
+
// Categorias inherentemente no analizables sin modelo dedicado
|
| 284 |
+
if (category === 'deportes') return false;
|
| 285 |
+
if (category === 'entretenimiento') return false;
|
| 286 |
+
|
| 287 |
+
return true;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
export function mapMarket(raw, { eventTitle = '', tagCategory = null } = {}) {
|
| 291 |
+
const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
|
| 292 |
+
const evTitle = eventTitle || raw.events?.[0]?.title || '';
|
| 293 |
+
const question = raw.question || '';
|
| 294 |
+
|
| 295 |
+
// Prioriza categoria del tag (proviene de Polymarket directamente)
|
| 296 |
+
// sobre el matcher por keywords, que es ruidoso.
|
| 297 |
+
const category = tagCategory || inferCategory(question, evTitle);
|
| 298 |
+
|
| 299 |
+
const spread = raw.spread != null ? parseFloat(raw.spread) : null;
|
| 300 |
+
const bestBid = raw.bestBid != null ? parseFloat(raw.bestBid) : null;
|
| 301 |
+
const bestAsk = raw.bestAsk != null ? parseFloat(raw.bestAsk) : null;
|
| 302 |
+
|
| 303 |
+
let clobTokenId = null;
|
| 304 |
+
try {
|
| 305 |
+
const tokens = JSON.parse(raw.clobTokenIds || '[]');
|
| 306 |
+
clobTokenId = tokens[0] ?? null;
|
| 307 |
+
} catch { /* ignorar */ }
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
id: String(raw.id),
|
| 311 |
+
question,
|
| 312 |
+
category,
|
| 313 |
+
countryCode: inferCountryCode(question, evTitle),
|
| 314 |
+
yesPrice,
|
| 315 |
+
noPrice,
|
| 316 |
+
volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
|
| 317 |
+
liquidityEur: raw.liquidity != null ? parseFloat(raw.liquidity) * USD_TO_EUR : null,
|
| 318 |
+
spread,
|
| 319 |
+
bestBid,
|
| 320 |
+
bestAsk,
|
| 321 |
+
clobTokenId,
|
| 322 |
+
analyzable: isAnalyzable(question, category),
|
| 323 |
+
status: mapStatus(raw),
|
| 324 |
+
closesAt: raw.endDate ? new Date(raw.endDate) : null,
|
| 325 |
+
lastSynced: new Date(),
|
| 326 |
+
};
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/**
|
| 330 |
+
* Obtiene eventos de Polymarket filtrados por tag_id ordenados por volumen 24h.
|
| 331 |
+
* Devuelve los markets aplanados de cada evento.
|
| 332 |
+
*/
|
| 333 |
+
async function fetchEventsByTag(tagId, limit) {
|
| 334 |
+
const url = `${GAMMA_EVENTS_URL}?active=true&closed=false&archived=false&tag_id=${tagId}&order=volume24hr&ascending=false&limit=${limit}`;
|
| 335 |
+
try {
|
| 336 |
+
const events = await httpGet(url);
|
| 337 |
+
if (!Array.isArray(events)) return [];
|
| 338 |
+
const flatMarkets = [];
|
| 339 |
+
for (const ev of events) {
|
| 340 |
+
const evTitle = ev.title || '';
|
| 341 |
+
for (const m of ev.markets || []) {
|
| 342 |
+
// Filtra mercados no activos a nivel de market
|
| 343 |
+
if (m.closed || m.archived || m.active === false) continue;
|
| 344 |
+
flatMarkets.push({ raw: m, eventTitle: evTitle });
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
return flatMarkets;
|
| 348 |
+
} catch (err) {
|
| 349 |
+
logger.warn({ err: err.message, tagId }, 'fetchEventsByTag failed');
|
| 350 |
+
return [];
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
/**
|
| 355 |
+
* Obtiene mercados activos de Polymarket de forma DIVERSIFICADA por tag.
|
| 356 |
+
*
|
| 357 |
+
* Problema previo: el endpoint /markets ignora tag_id y devuelve siempre la
|
| 358 |
+
* misma "home feed" de Polymarket (dominada por US politics + World Cup).
|
| 359 |
+
*
|
| 360 |
+
* Solucion: iteramos sobre el endpoint /events (que SI respeta tag_id) con
|
| 361 |
+
* una lista curada de tags de alto valor accionable (cripto, fed, tech,
|
| 362 |
+
* geopolitica, energia, etc) y aplanamos los mercados de cada evento.
|
| 363 |
+
*
|
| 364 |
+
* @returns {Promise<Market[]>} Mercados unicos con categoria asignada por tag.
|
| 365 |
+
*/
|
| 366 |
+
export async function fetchActiveMarkets() {
|
| 367 |
+
const results = await Promise.all(
|
| 368 |
+
TAG_SLICES.map((slice) => fetchEventsByTag(slice.tagId, slice.limit)),
|
| 369 |
+
);
|
| 370 |
+
|
| 371 |
+
// Dedup por id, preservando la categoria de la PRIMERA aparicion
|
| 372 |
+
// (los slices estan ordenados por prioridad de alpha financiero).
|
| 373 |
+
const seen = new Map(); // id → { raw, eventTitle, tagCategory }
|
| 374 |
+
results.forEach((bucket, i) => {
|
| 375 |
+
const tagCategory = TAG_SLICES[i].category;
|
| 376 |
+
for (const { raw, eventTitle } of bucket) {
|
| 377 |
+
const id = String(raw.id);
|
| 378 |
+
if (seen.has(id)) continue;
|
| 379 |
+
seen.set(id, { raw, eventTitle, tagCategory });
|
| 380 |
+
}
|
| 381 |
+
});
|
| 382 |
+
|
| 383 |
+
const mapped = Array.from(seen.values()).map(({ raw, eventTitle, tagCategory }) =>
|
| 384 |
+
mapMarket(raw, { eventTitle, tagCategory }),
|
| 385 |
+
);
|
| 386 |
+
|
| 387 |
+
// Filtro de calidad: liquidez minima 5000 EUR para excluir orderbooks muertos
|
| 388 |
+
const filtered = mapped.filter((m) => (m.liquidityEur ?? 0) >= 5000 || (m.volumeEur ?? 0) >= 50000);
|
| 389 |
+
|
| 390 |
+
logger.info({
|
| 391 |
+
totalFetched: mapped.length,
|
| 392 |
+
afterLiquidityFilter: filtered.length,
|
| 393 |
+
perCategory: filtered.reduce((acc, m) => { acc[m.category] = (acc[m.category]||0)+1; return acc; }, {}),
|
| 394 |
+
}, 'polymarket diversified fetch complete');
|
| 395 |
+
|
| 396 |
+
return filtered;
|
| 397 |
+
}
|
backend/src/positions/kelly.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Criterio de Kelly para sizing de posiciones, limitado al 25%.
|
| 3 |
+
*
|
| 4 |
+
* Formula:
|
| 5 |
+
* odds = (1 / price) - 1
|
| 6 |
+
* k = (odds * pWin - pLose) / odds
|
| 7 |
+
* return min(max(k, 0), 0.25)
|
| 8 |
+
*
|
| 9 |
+
* Parametros:
|
| 10 |
+
* - price : precio actual del outcome (0 < price < 1).
|
| 11 |
+
* - pWin : probabilidad de ganar (usamos confidence de la senal IA).
|
| 12 |
+
*
|
| 13 |
+
* Devuelve:
|
| 14 |
+
* Fraccion del capital virtual recomendada (0.0 a 0.25).
|
| 15 |
+
*/
|
| 16 |
+
export function kellyFraction(price, pWin) {
|
| 17 |
+
if (!price || price <= 0 || price >= 1 || !pWin) return 0;
|
| 18 |
+
const pLose = 1 - pWin;
|
| 19 |
+
const odds = 1 / price - 1;
|
| 20 |
+
const k = (odds * pWin - pLose) / odds;
|
| 21 |
+
return Math.min(Math.max(k, 0), 0.25);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const SPREAD_ILLIQUID_THRESHOLD = 0.05; // 5c bid/ask → mercado ilíquido
|
| 25 |
+
const KELLY_CONSERVATIVE_FACTOR = 0.25; // Quarter-Kelly por seguridad (LLM no calibrada)
|
| 26 |
+
const MAX_BANKROLL_FRACTION = 0.25; // No mas del 25% del bankroll por mercado
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Calcula una sugerencia de tamano de posicion *aware* del spread bid/ask.
|
| 30 |
+
*
|
| 31 |
+
* Resta el coste de ejecucion (spread) al edge bruto que reporta la IA. Si el
|
| 32 |
+
* edge neto es <= 0, devuelve 0 (no recomienda apostar). Marca ilíquidos los
|
| 33 |
+
* mercados con spread > 5c para que el frontend pueda deshabilitar el boton.
|
| 34 |
+
*
|
| 35 |
+
* @param {Object} args
|
| 36 |
+
* @param {number} args.yesPrice precio YES actual (0-1)
|
| 37 |
+
* @param {number} args.noPrice precio NO actual (0-1)
|
| 38 |
+
* @param {number} args.spread bid/ask spread (0-1) reportado por Polymarket
|
| 39 |
+
* @param {Object} args.signal { signal, confidence, edgePoints, fairProb }
|
| 40 |
+
* @param {number} [args.bankroll=1000] capital virtual base (€)
|
| 41 |
+
* @returns {{ outcome:'YES'|'NO'|null, fraction:number, amountEur:number,
|
| 42 |
+
* edgeNet:number, illiquid:boolean, note:string }}
|
| 43 |
+
*/
|
| 44 |
+
export function suggestSize({ yesPrice, noPrice, spread = 0, signal, bankroll = 1000 }) {
|
| 45 |
+
const illiquid = (spread ?? 0) > SPREAD_ILLIQUID_THRESHOLD;
|
| 46 |
+
|
| 47 |
+
if (!signal || signal.edgePoints == null) {
|
| 48 |
+
return {
|
| 49 |
+
outcome: null,
|
| 50 |
+
fraction: 0,
|
| 51 |
+
amountEur: 0,
|
| 52 |
+
edgeNet: 0,
|
| 53 |
+
illiquid,
|
| 54 |
+
note: illiquid
|
| 55 |
+
? `Mercado ilíquido (spread ${Math.round((spread ?? 0) * 100)}¢). Compra desaconsejada.`
|
| 56 |
+
: 'Sin señal IA disponible. No se puede calcular sugerencia.',
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const rawEdge = Math.abs(signal.edgePoints) / 100; // 0-1 (probabilidad)
|
| 61 |
+
const netEdge = rawEdge - (spread ?? 0);
|
| 62 |
+
const outcome = signal.edgePoints > 0 ? 'YES' : 'NO';
|
| 63 |
+
const price = outcome === 'YES' ? yesPrice : noPrice;
|
| 64 |
+
|
| 65 |
+
if (netEdge <= 0.005 || !price || price <= 0 || price >= 1) {
|
| 66 |
+
return {
|
| 67 |
+
outcome: null,
|
| 68 |
+
fraction: 0,
|
| 69 |
+
amountEur: 0,
|
| 70 |
+
edgeNet: netEdge,
|
| 71 |
+
illiquid,
|
| 72 |
+
note: illiquid
|
| 73 |
+
? `Mercado ilíquido (spread ${Math.round((spread ?? 0) * 100)}¢).`
|
| 74 |
+
: `Sin edge neto tras spread (${(rawEdge * 100).toFixed(1)}pp − ${((spread ?? 0) * 100).toFixed(1)}pp). No apostar.`,
|
| 75 |
+
};
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Probabilidad efectiva = implied + edgeNet, capada en [0,1)
|
| 79 |
+
const pWin = Math.max(0, Math.min(0.99, price + netEdge));
|
| 80 |
+
// Quarter-Kelly por seguridad: la confianza del LLM no esta calibrada.
|
| 81 |
+
const k = kellyFraction(price, pWin) * KELLY_CONSERVATIVE_FACTOR;
|
| 82 |
+
const fraction = Math.min(MAX_BANKROLL_FRACTION, Math.max(0, k));
|
| 83 |
+
const amountEur = Math.round(bankroll * fraction);
|
| 84 |
+
|
| 85 |
+
return {
|
| 86 |
+
outcome,
|
| 87 |
+
fraction,
|
| 88 |
+
amountEur,
|
| 89 |
+
edgeNet: netEdge,
|
| 90 |
+
illiquid,
|
| 91 |
+
note: illiquid
|
| 92 |
+
? `Mercado ilíquido (spread ${Math.round((spread ?? 0) * 100)}¢). Compra desaconsejada.`
|
| 93 |
+
: `Kelly conservador (¼) sobre edge neto ${(netEdge * 100).toFixed(1)}pp: €${amountEur} en ${outcome}.`,
|
| 94 |
+
};
|
| 95 |
+
}
|
backend/src/positions/positions.repository.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Repositorio de acceso a datos para el modelo Position.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - create(data) → inserta una nueva posicion virtual.
|
| 6 |
+
* - findByUser(userId, status) → lista posiciones del usuario con datos del mercado.
|
| 7 |
+
* - findByIdAndUser(id, userId) → busca posicion propia del usuario.
|
| 8 |
+
* - findAllOpen() → todas las posiciones abiertas (para recalcular P&L).
|
| 9 |
+
* - update(id, data) → actualiza precio, P&L, estado, etc.
|
| 10 |
+
*
|
| 11 |
+
* Todas las operaciones usan Prisma ORM.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { prisma } from '../utils/prisma.js';
|
| 15 |
+
|
| 16 |
+
export const positionsRepository = {
|
| 17 |
+
create(data) {
|
| 18 |
+
return prisma.position.create({ data });
|
| 19 |
+
},
|
| 20 |
+
|
| 21 |
+
findByUser(userId, status) {
|
| 22 |
+
return prisma.position.findMany({
|
| 23 |
+
where: { userId, ...(status ? { status } : {}) },
|
| 24 |
+
include: { market: { select: { id: true, question: true, yesPrice: true, noPrice: true, status: true } } },
|
| 25 |
+
orderBy: { openedAt: 'desc' },
|
| 26 |
+
});
|
| 27 |
+
},
|
| 28 |
+
|
| 29 |
+
findByIdAndUser(id, userId) {
|
| 30 |
+
return prisma.position.findFirst({ where: { id, userId } });
|
| 31 |
+
},
|
| 32 |
+
|
| 33 |
+
findAllOpen() {
|
| 34 |
+
return prisma.position.findMany({
|
| 35 |
+
where: { status: 'open' },
|
| 36 |
+
include: { market: { select: { yesPrice: true, noPrice: true } } },
|
| 37 |
+
});
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
update(id, data) {
|
| 41 |
+
return prisma.position.update({ where: { id }, data });
|
| 42 |
+
},
|
| 43 |
+
};
|
backend/src/positions/positions.routes.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Rutas REST del modulo de posiciones (simulador virtual).
|
| 3 |
+
*
|
| 4 |
+
* Endpoints (montados en /api/v1/positions):
|
| 5 |
+
* POST / → abrir posicion (validate openBody).
|
| 6 |
+
* GET / → listar posiciones (validate listQuery en query).
|
| 7 |
+
* DELETE /:id → cerrar posicion (validate idParam en params).
|
| 8 |
+
*
|
| 9 |
+
* Todas las rutas requieren autenticacion JWT (router.use(requireAuth)).
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { Router } from 'express';
|
| 13 |
+
import { validate } from '../middlewares/validate.js';
|
| 14 |
+
import { requireAuth } from '../middlewares/requireAuth.js';
|
| 15 |
+
import { positionsController } from './positions.controller.js';
|
| 16 |
+
import { openBody, idParam, listQuery } from './positions.validators.js';
|
| 17 |
+
|
| 18 |
+
const router = Router();
|
| 19 |
+
|
| 20 |
+
// Endpoint publico (sugerencia de sizing) - no requiere autenticacion
|
| 21 |
+
router.get('/suggestion/:marketId', positionsController.suggest);
|
| 22 |
+
|
| 23 |
+
router.use(requireAuth);
|
| 24 |
+
|
| 25 |
+
router.post('/', validate(openBody), positionsController.open);
|
| 26 |
+
router.get('/', validate(listQuery, 'query'), positionsController.list);
|
| 27 |
+
router.delete('/:id', validate(idParam, 'params'), positionsController.close);
|
| 28 |
+
|
| 29 |
+
export default router;
|
backend/src/positions/positions.service.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Logica de negocio del modulo de posiciones (simulador virtual).
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - open(userId, { marketId, outcome, amountEur })
|
| 6 |
+
* → valida que el mercado exista y este activo, obtiene precio de entrada,
|
| 7 |
+
* calcula fraccion de Kelly y crea la posicion.
|
| 8 |
+
* - list(userId, status) → devuelve posiciones del usuario.
|
| 9 |
+
* - close(id, userId) → calcula P&L final con el precio actual y marca como cerrada.
|
| 10 |
+
* - updateAllPnL() → recalcula P&L de todas las posiciones abiertas (scheduler).
|
| 11 |
+
*
|
| 12 |
+
* Calculos:
|
| 13 |
+
* - P&L = amountEur * (currentPrice / entryPrice - 1).
|
| 14 |
+
* - Kelly = (odds * pWin - pLose) / odds, capped al 25%.
|
| 15 |
+
*
|
| 16 |
+
* Consumido por:
|
| 17 |
+
* - positions.controller.js (API REST).
|
| 18 |
+
* - scheduler.js (updatePositionsPnL cada 30s).
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
import { HttpError } from '../utils/apiResponse.js';
|
| 22 |
+
import { positionsRepository } from './positions.repository.js';
|
| 23 |
+
import { marketsRepository } from '../markets/markets.repository.js';
|
| 24 |
+
import { signalsRepository } from '../signals/signals.repository.js';
|
| 25 |
+
import { kellyFraction, suggestSize } from './kelly.js';
|
| 26 |
+
|
| 27 |
+
function currentPriceForOutcome(market, outcome) {
|
| 28 |
+
return outcome === 'YES' ? market.yesPrice : market.noPrice;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function calcPnl(amountEur, entryPrice, currentPrice) {
|
| 32 |
+
if (!currentPrice || !entryPrice) return 0;
|
| 33 |
+
return amountEur * (currentPrice / entryPrice - 1);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export const positionsService = {
|
| 37 |
+
async open(userId, { marketId, outcome, amountEur }) {
|
| 38 |
+
const market = await marketsRepository.findById(marketId);
|
| 39 |
+
if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
|
| 40 |
+
if (market.status !== 'active') throw new HttpError(409, 'MARKET_CLOSED', 'Market is not active');
|
| 41 |
+
|
| 42 |
+
const entryPrice = currentPriceForOutcome(market, outcome);
|
| 43 |
+
if (!entryPrice) throw new HttpError(409, 'NO_PRICE', 'Market price unavailable');
|
| 44 |
+
|
| 45 |
+
const latestSignal = await signalsRepository.findLatestByMarket(marketId);
|
| 46 |
+
const confidence = latestSignal?.confidence ?? 0.5;
|
| 47 |
+
const fraction = kellyFraction(entryPrice, confidence);
|
| 48 |
+
|
| 49 |
+
return positionsRepository.create({
|
| 50 |
+
userId,
|
| 51 |
+
marketId,
|
| 52 |
+
outcome,
|
| 53 |
+
amountEur,
|
| 54 |
+
entryPrice,
|
| 55 |
+
currentPrice: entryPrice,
|
| 56 |
+
pnl: 0,
|
| 57 |
+
kellyFraction: fraction,
|
| 58 |
+
status: 'open',
|
| 59 |
+
});
|
| 60 |
+
},
|
| 61 |
+
|
| 62 |
+
list(userId, status) {
|
| 63 |
+
return positionsRepository.findByUser(userId, status);
|
| 64 |
+
},
|
| 65 |
+
|
| 66 |
+
async close(id, userId) {
|
| 67 |
+
const position = await positionsRepository.findByIdAndUser(id, userId);
|
| 68 |
+
if (!position) throw new HttpError(404, 'NOT_FOUND', 'Position not found');
|
| 69 |
+
if (position.status === 'closed') throw new HttpError(409, 'ALREADY_CLOSED', 'Position already closed');
|
| 70 |
+
|
| 71 |
+
const market = await marketsRepository.findById(position.marketId);
|
| 72 |
+
const currentPrice = market ? currentPriceForOutcome(market, position.outcome) : position.entryPrice;
|
| 73 |
+
const finalPnl = calcPnl(position.amountEur, position.entryPrice, currentPrice);
|
| 74 |
+
|
| 75 |
+
return positionsRepository.update(id, {
|
| 76 |
+
status: 'closed',
|
| 77 |
+
currentPrice,
|
| 78 |
+
pnl: finalPnl,
|
| 79 |
+
closedAt: new Date(),
|
| 80 |
+
});
|
| 81 |
+
},
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* Sugiere outcome y tamano de posicion para un mercado, basado en:
|
| 85 |
+
* - Spread bid/ask (resta del edge)
|
| 86 |
+
* - Edge de la senal IA mas reciente (impliedProb vs fairProb)
|
| 87 |
+
* - Quarter-Kelly capado al 25% del bankroll
|
| 88 |
+
*
|
| 89 |
+
* Devuelve { outcome, fraction, amountEur, edgeNet, illiquid, note }.
|
| 90 |
+
*/
|
| 91 |
+
async suggest(marketId, bankroll = 1000) {
|
| 92 |
+
const market = await marketsRepository.findById(marketId);
|
| 93 |
+
if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
|
| 94 |
+
|
| 95 |
+
const signal = await signalsRepository.findLatestByMarket(marketId);
|
| 96 |
+
|
| 97 |
+
return suggestSize({
|
| 98 |
+
yesPrice: market.yesPrice,
|
| 99 |
+
noPrice: market.noPrice,
|
| 100 |
+
spread: market.spread ?? 0,
|
| 101 |
+
signal,
|
| 102 |
+
bankroll,
|
| 103 |
+
});
|
| 104 |
+
},
|
| 105 |
+
|
| 106 |
+
async updateAllPnL() {
|
| 107 |
+
const open = await positionsRepository.findAllOpen();
|
| 108 |
+
await Promise.all(
|
| 109 |
+
open.map((pos) => {
|
| 110 |
+
const currentPrice = currentPriceForOutcome(pos.market, pos.outcome);
|
| 111 |
+
if (!currentPrice) return Promise.resolve();
|
| 112 |
+
return positionsRepository.update(pos.id, {
|
| 113 |
+
currentPrice,
|
| 114 |
+
pnl: calcPnl(pos.amountEur, pos.entryPrice, currentPrice),
|
| 115 |
+
});
|
| 116 |
+
}),
|
| 117 |
+
);
|
| 118 |
+
},
|
| 119 |
+
};
|
backend/src/utils/coingecko.client.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente CoinGecko para spot prices de cripto.
|
| 3 |
+
*
|
| 4 |
+
* Sin API key (free tier publico). Rate limit ~30 req/min — usamos cache TTL 60s
|
| 5 |
+
* para evitar excederlo cuando hay muchos mercados de cripto en un ciclo.
|
| 6 |
+
*
|
| 7 |
+
* Consumido por:
|
| 8 |
+
* - signals/aiPipeline.js → enriquece el prompt con ground truth para
|
| 9 |
+
* mercados de precio objetivo (BTC $150k, ETH $5k, etc).
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { httpGet } from './httpClient.js';
|
| 13 |
+
import { logger } from './logger.js';
|
| 14 |
+
|
| 15 |
+
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
|
| 16 |
+
const CACHE_TTL_MS = 60_000;
|
| 17 |
+
|
| 18 |
+
// Mapeo symbol → id de CoinGecko
|
| 19 |
+
const COIN_IDS = {
|
| 20 |
+
BTC: 'bitcoin',
|
| 21 |
+
ETH: 'ethereum',
|
| 22 |
+
SOL: 'solana',
|
| 23 |
+
DOGE: 'dogecoin',
|
| 24 |
+
XRP: 'ripple',
|
| 25 |
+
ADA: 'cardano',
|
| 26 |
+
AVAX: 'avalanche-2',
|
| 27 |
+
LINK: 'chainlink',
|
| 28 |
+
MATIC: 'matic-network',
|
| 29 |
+
DOT: 'polkadot',
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
let cache = { data: null, timestamp: 0 };
|
| 33 |
+
|
| 34 |
+
async function refreshCache() {
|
| 35 |
+
const ids = Object.values(COIN_IDS).join(',');
|
| 36 |
+
const url = `${COINGECKO_URL}?ids=${ids}&vs_currencies=usd`;
|
| 37 |
+
try {
|
| 38 |
+
const data = await httpGet(url, { timeout: 8000, retries: 1 });
|
| 39 |
+
// Normaliza: { bitcoin: { usd: 103400 }, ... } → { BTC: 103400, ... }
|
| 40 |
+
const normalized = {};
|
| 41 |
+
for (const [symbol, id] of Object.entries(COIN_IDS)) {
|
| 42 |
+
if (data[id]?.usd != null) normalized[symbol] = data[id].usd;
|
| 43 |
+
}
|
| 44 |
+
cache = { data: normalized, timestamp: Date.now() };
|
| 45 |
+
return normalized;
|
| 46 |
+
} catch (err) {
|
| 47 |
+
logger.warn({ err: err.message }, 'CoinGecko fetch failed');
|
| 48 |
+
return cache.data || {};
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Devuelve { BTC: 103400, ETH: 3450, SOL: 142, ... } en USD.
|
| 54 |
+
* Cache TTL 60s.
|
| 55 |
+
*/
|
| 56 |
+
export async function getSpotPrices() {
|
| 57 |
+
if (cache.data && Date.now() - cache.timestamp < CACHE_TTL_MS) {
|
| 58 |
+
return cache.data;
|
| 59 |
+
}
|
| 60 |
+
return refreshCache();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Detecta si una pregunta de mercado es de "precio objetivo cripto".
|
| 65 |
+
* Devuelve { symbol, targetPrice, currentPrice, requiredMovePct, deadline } o null.
|
| 66 |
+
*/
|
| 67 |
+
export async function analyzeCryptoTarget(question) {
|
| 68 |
+
if (!question) return null;
|
| 69 |
+
|
| 70 |
+
// Detecta symbol (BTC|Bitcoin|ETH|Ethereum|SOL|Solana)
|
| 71 |
+
const symbolMap = [
|
| 72 |
+
{ rx: /\b(bitcoin|btc)\b/i, sym: 'BTC' },
|
| 73 |
+
{ rx: /\b(ethereum|eth)\b/i, sym: 'ETH' },
|
| 74 |
+
{ rx: /\b(solana|sol)\b/i, sym: 'SOL' },
|
| 75 |
+
{ rx: /\b(dogecoin|doge)\b/i, sym: 'DOGE' },
|
| 76 |
+
{ rx: /\b(xrp|ripple)\b/i, sym: 'XRP' },
|
| 77 |
+
{ rx: /\b(cardano|ada)\b/i, sym: 'ADA' },
|
| 78 |
+
];
|
| 79 |
+
const match = symbolMap.find(({ rx }) => rx.test(question));
|
| 80 |
+
if (!match) return null;
|
| 81 |
+
|
| 82 |
+
// Detecta target price ($150k, $3,500, $100,000)
|
| 83 |
+
const priceMatch = question.match(/\$\s?([\d,]+(?:\.\d+)?)\s?([kKmM])?/);
|
| 84 |
+
if (!priceMatch) return null;
|
| 85 |
+
let target = parseFloat(priceMatch[1].replace(/,/g, ''));
|
| 86 |
+
if (priceMatch[2]?.toLowerCase() === 'k') target *= 1000;
|
| 87 |
+
if (priceMatch[2]?.toLowerCase() === 'm') target *= 1_000_000;
|
| 88 |
+
|
| 89 |
+
const spots = await getSpotPrices();
|
| 90 |
+
const current = spots[match.sym];
|
| 91 |
+
if (!current || !target) return null;
|
| 92 |
+
|
| 93 |
+
const requiredMovePct = ((target - current) / current) * 100;
|
| 94 |
+
return {
|
| 95 |
+
symbol: match.sym,
|
| 96 |
+
currentPrice: current,
|
| 97 |
+
targetPrice: target,
|
| 98 |
+
requiredMovePct,
|
| 99 |
+
};
|
| 100 |
+
}
|
backend/src/utils/httpClient.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cliente HTTP reutilizable con retry y timeout.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - Realizar peticiones GET y POST usando fetch nativo de Node.js.
|
| 6 |
+
* - Aplicar timeout via AbortController.
|
| 7 |
+
* - Reintentar automaticamente ante errores de red o HTTP 429/5xx.
|
| 8 |
+
* - Backoff exponencial con tope de 30s.
|
| 9 |
+
* - Inyectar User-Agent: PolySignal/1.0 en todas las peticiones.
|
| 10 |
+
*
|
| 11 |
+
* Uso:
|
| 12 |
+
* const data = await httpGet(url, { headers, timeout, retries });
|
| 13 |
+
* const data = await httpPost(url, body, { headers, timeout, retries });
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { logger } from './logger.js';
|
| 17 |
+
|
| 18 |
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
| 19 |
+
const DEFAULT_RETRIES = 3;
|
| 20 |
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
|
| 21 |
+
|
| 22 |
+
function backoff(attempt) {
|
| 23 |
+
return Math.min(1_000 * 2 ** attempt, 30_000);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async function request(url, init = {}, { timeout = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES } = {}) {
|
| 27 |
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
| 28 |
+
const controller = new AbortController();
|
| 29 |
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
| 30 |
+
|
| 31 |
+
try {
|
| 32 |
+
const res = await fetch(url, {
|
| 33 |
+
...init,
|
| 34 |
+
headers: { 'User-Agent': 'PolySignal/1.0', ...init.headers },
|
| 35 |
+
signal: controller.signal,
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
clearTimeout(timeoutId);
|
| 39 |
+
|
| 40 |
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < retries) {
|
| 41 |
+
const wait = backoff(attempt);
|
| 42 |
+
logger.warn({ url, status: res.status, attempt, wait }, 'retrying request');
|
| 43 |
+
await new Promise((r) => setTimeout(r, wait));
|
| 44 |
+
continue;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const text = await res.text();
|
| 48 |
+
const data = text ? JSON.parse(text) : null;
|
| 49 |
+
|
| 50 |
+
if (!res.ok) {
|
| 51 |
+
const err = Object.assign(new Error(`HTTP ${res.status} — ${url}`), {
|
| 52 |
+
status: res.status,
|
| 53 |
+
body: data,
|
| 54 |
+
});
|
| 55 |
+
throw err;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return data;
|
| 59 |
+
} catch (err) {
|
| 60 |
+
clearTimeout(timeoutId);
|
| 61 |
+
|
| 62 |
+
if (err.name === 'AbortError') {
|
| 63 |
+
throw Object.assign(new Error(`Timeout after ${timeout}ms — ${url}`), { code: 'TIMEOUT' });
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// network error (no HTTP status) → retry
|
| 67 |
+
if (!err.status && attempt < retries) {
|
| 68 |
+
const wait = backoff(attempt);
|
| 69 |
+
logger.warn({ url, err: err.message, attempt, wait }, 'network error, retrying');
|
| 70 |
+
await new Promise((r) => setTimeout(r, wait));
|
| 71 |
+
continue;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
logger.error({ url, err: err.message }, 'request failed');
|
| 75 |
+
throw err;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export const httpGet = (url, { headers, ...opts } = {}) =>
|
| 81 |
+
request(url, { method: 'GET', headers }, opts);
|
| 82 |
+
|
| 83 |
+
export const httpPost = (url, body, { headers, ...opts } = {}) =>
|
| 84 |
+
request(
|
| 85 |
+
url,
|
| 86 |
+
{ method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) },
|
| 87 |
+
opts,
|
| 88 |
+
);
|
backend/src/utils/prisma.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Instancia singleton de PrismaClient.
|
| 3 |
+
*
|
| 4 |
+
* Responsabilidades:
|
| 5 |
+
* - Gestionar la conexion a SQLite (desarrollo) o PostgreSQL (produccion).
|
| 6 |
+
* - Reutilizar la misma instancia en hot-reload (guardada en globalThis).
|
| 7 |
+
* - Loguear solo warns y errores para no saturar la salida.
|
| 8 |
+
*
|
| 9 |
+
* Relaciones:
|
| 10 |
+
* - Todos los repositories importan este prisma para ejecutar queries.
|
| 11 |
+
* - schema.prisma define los modelos: User, Market, AISignal, Position, Watchlist, Alert.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { PrismaClient } from '@prisma/client';
|
| 15 |
+
|
| 16 |
+
const globalForPrisma = globalThis;
|
| 17 |
+
|
| 18 |
+
export const prisma =
|
| 19 |
+
globalForPrisma.__prisma__ ??
|
| 20 |
+
new PrismaClient({
|
| 21 |
+
log: ['warn', 'error'],
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
if (process.env.NODE_ENV !== 'production') {
|
| 25 |
+
globalForPrisma.__prisma__ = prisma;
|
| 26 |
+
}
|