blackmistcode commited on
Commit
dfe11f8
·
verified ·
1 Parent(s): 7dcf18a

Add files using upload-large-folder tool

Browse files
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, '&amp;')
30
+ .replace(/</g, '&lt;')
31
+ .replace(/>/g, '&gt;')
32
+ .replace(/"/g, '&quot;');
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
+ }