Jose Salazar commited on
Commit
714ef9d
·
1 Parent(s): 94ad3aa

Integracion parcial de backend con frontend, se anexa las capitales y mapa de arnau

Browse files
backend/src/app.js CHANGED
@@ -33,6 +33,7 @@ import signalsRoutes from './signals/signals.routes.js';
33
  import positionsRoutes from './positions/positions.routes.js';
34
  import watchlistRoutes from './watchlist/watchlist.routes.js';
35
  import alertsRoutes from './alerts/alerts.routes.js';
 
36
  import { notFound } from './middlewares/notFound.js';
37
  import { errorHandler } from './middlewares/errorHandler.js';
38
 
@@ -40,10 +41,13 @@ const app = express();
40
 
41
  app.use(helmet());
42
  app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
 
 
 
43
  app.use(
44
  rateLimit({
45
  windowMs: 15 * 60 * 1000,
46
- max: 200,
47
  standardHeaders: true,
48
  legacyHeaders: false,
49
  message: { ok: false, error: { code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded' } },
@@ -58,6 +62,7 @@ app.use('/api/v1/markets', signalsRoutes);
58
  app.use('/api/v1/positions', positionsRoutes);
59
  app.use('/api/v1/watchlist', watchlistRoutes);
60
  app.use('/api/v1/alerts', alertsRoutes);
 
61
 
62
  app.use(notFound);
63
  app.use(errorHandler);
 
33
  import positionsRoutes from './positions/positions.routes.js';
34
  import watchlistRoutes from './watchlist/watchlist.routes.js';
35
  import alertsRoutes from './alerts/alerts.routes.js';
36
+ import statsRoutes from './stats/stats.routes.js';
37
  import { notFound } from './middlewares/notFound.js';
38
  import { errorHandler } from './middlewares/errorHandler.js';
39
 
 
41
 
42
  app.use(helmet());
43
  app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
44
+
45
+ // Rate limit: muy permisivo en desarrollo, restrictivo en producción
46
+ const rateLimitMax = config.NODE_ENV === 'production' ? 200 : 5000;
47
  app.use(
48
  rateLimit({
49
  windowMs: 15 * 60 * 1000,
50
+ max: rateLimitMax,
51
  standardHeaders: true,
52
  legacyHeaders: false,
53
  message: { ok: false, error: { code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded' } },
 
62
  app.use('/api/v1/positions', positionsRoutes);
63
  app.use('/api/v1/watchlist', watchlistRoutes);
64
  app.use('/api/v1/alerts', alertsRoutes);
65
+ app.use('/api/v1/stats', statsRoutes);
66
 
67
  app.use(notFound);
68
  app.use(errorHandler);
backend/src/markets/polymarket.client.js CHANGED
@@ -37,13 +37,166 @@ function parsePrices(outcomePrices) {
37
  }
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  export function mapMarket(raw) {
41
  const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
 
 
 
42
  return {
43
  id: String(raw.id),
44
- question: raw.question,
45
- category: null,
46
- countryCode: null,
47
  yesPrice,
48
  noPrice,
49
  volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
 
37
  }
38
  }
39
 
40
+ function inferCategory(question, eventTitle = '') {
41
+ const text = `${question} ${eventTitle}`.toLowerCase();
42
+
43
+ const rules = [
44
+ { keywords: ['bitcoin', 'btc', 'ethereum', 'eth', 'crypto', 'blockchain', 'solana', 'cardano', 'altcoin', 'defi', 'nft'], category: 'cripto' },
45
+ { 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' },
46
+ { keywords: ['trump', 'biden', 'election', 'president', 'democrat', 'republican', 'congress', 'senate', 'house', 'vote', 'impeach', 'nominee', 'primary', 'governor', 'mayor', 'political', 'politics', 'campaign'], category: 'política' },
47
+ { keywords: ['war', 'ukraine', 'russia', 'putin', 'china', 'xi', 'iran', 'israel', 'gaza', 'north korea', 'taiwan', 'invasion', 'missile', 'nuclear', 'sanctions', 'diplomatic', 'embassy', 'conflict'], category: 'geopolítica' },
48
+ { keywords: ['super bowl', 'world cup', 'olympics', 'nba', 'nfl', 'mlb', 'soccer', 'football', 'tennis', 'golf', 'mvp', 'championship', 'fifa', 'uefa', 'premier league', 'playoff'], category: 'deportes' },
49
+ { keywords: ['album', 'movie', 'oscar', 'grammy', 'emmy', 'hollywood', 'actor', 'singer', 'celebrity', 'gta', 'video game', 'song', 'chart', 'streaming', 'netflix', 'disney', 'marvel', 'rockstar'], category: 'entretenimiento' },
50
+ { keywords: ['ai', 'spacex', 'mars', 'rocket', 'vaccine', 'climate', 'covid', 'pandemic', 'tesla', 'elon', 'neuralink', 'fusion', 'crispr'], category: 'ciencia' },
51
+ ];
52
+
53
+ for (const rule of rules) {
54
+ if (rule.keywords.some((kw) => text.includes(kw))) {
55
+ return rule.category;
56
+ }
57
+ }
58
+
59
+ return 'general';
60
+ }
61
+
62
+ function inferCountryCode(question, eventTitle = '') {
63
+ // Envolver con espacios para evitar coincidencias parciales de substrings
64
+ const text = ` ${question} ${eventTitle} `.toLowerCase();
65
+
66
+ const rules = [
67
+ // Estados Unidos
68
+ { 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' },
69
+ // Reino Unido
70
+ { keywords: [' uk ', ' britain ', ' british ', ' england ', ' london ', ' brexit ', ' boe ', ' pound ', ' sterling ', ' scotland ', ' wales ', ' king charles ', ' prime minister ', ' parliament ', ' westminster ', ' tory ', ' labour party '], code: 'GB' },
71
+ // Alemania
72
+ { keywords: [' germany ', ' german ', ' merkel ', ' scholz ', ' berlin ', ' bundestag ', ' deutsche '], code: 'DE' },
73
+ // Francia
74
+ { keywords: [' france ', ' french ', ' macron ', ' paris ', ' le pen ', ' élysée '], code: 'FR' },
75
+ // Italia
76
+ { keywords: [' italy ', ' italian ', ' meloni ', ' rome ', ' berlusconi '], code: 'IT' },
77
+ // España
78
+ { keywords: [' spain ', ' spanish ', ' sánchez ', ' madrid ', ' catalonia ', ' catalan '], code: 'ES' },
79
+ // China
80
+ { keywords: [' china ', ' chinese ', ' xi jinping ', ' beijing ', ' shanghai ', ' hong kong ', ' taiwan ', ' yuan ', ' alibaba ', ' byd '], code: 'CN' },
81
+ // Rusia
82
+ { keywords: [' russia ', ' russian ', ' putin ', ' moscow ', ' kremlin ', ' ruble '], code: 'RU' },
83
+ // India
84
+ { keywords: [' india ', ' indian ', ' modi ', ' mumbai ', ' delhi ', ' rupee ', ' bjp '], code: 'IN' },
85
+ // Brasil
86
+ { keywords: [' brazil ', ' brazilian ', ' brasil ', ' lula ', ' bolsonaro ', ' real '], code: 'BR' },
87
+ // Japón
88
+ { keywords: [' japan ', ' japanese ', ' tokyo ', ' boj ', ' yen ', ' nikkei ', ' suzuki '], code: 'JP' },
89
+ // Canadá
90
+ { keywords: [' canada ', ' canadian ', ' trudeau ', ' toronto ', ' loonie '], code: 'CA' },
91
+ // Ucrania
92
+ { keywords: [' ukraine ', ' ukrainian ', ' kyiv ', ' zelensky '], code: 'UA' },
93
+ // Israel
94
+ { keywords: [' israel ', ' israeli ', ' gaza ', ' netanyahu ', ' palestine ', ' palestinian ', ' hamas '], code: 'IL' },
95
+ // Irán
96
+ { keywords: [' iran ', ' iranian ', ' tehran ', ' ayatollah '], code: 'IR' },
97
+ // Corea
98
+ { keywords: [' korea ', ' north korea ', ' south korea ', ' korean ', ' seoul ', ' kim jong '], code: 'KR' },
99
+ // Australia
100
+ { keywords: [' australia ', ' australian ', ' sydney ', ' rba ', ' aud '], code: 'AU' },
101
+ // México
102
+ { keywords: [' mexico ', ' mexican ', ' peso ', ' amlo ', ' mexican president '], code: 'MX' },
103
+ // Turquía
104
+ { keywords: [' turkey ', ' turkish ', ' erdogan ', ' lira ', ' istanbul '], code: 'TR' },
105
+ // Arabia Saudita
106
+ { keywords: [' saudi ', ' saudi arabia ', ' riyadh ', ' aramco '], code: 'SA' },
107
+ // Sudáfrica
108
+ { keywords: [' south africa ', ' south african ', ' johannesburg ', ' rand '], code: 'ZA' },
109
+ // Argentina
110
+ { keywords: [' argentina ', ' argentinian ', ' milei ', ' buenos aires ', ' peso argentino '], code: 'AR' },
111
+ // Uzbekistán
112
+ { keywords: [' uzbekistan ', ' uzbek '], code: 'UZ' },
113
+ // Nueva Zelanda
114
+ { keywords: [' new zealand ', ' kiwi '], code: 'NZ' },
115
+ // Países Bajos
116
+ { keywords: [' netherlands ', ' dutch ', ' amsterdam ', ' rutte '], code: 'NL' },
117
+ // Polonia
118
+ { keywords: [' poland ', ' polish ', ' warsaw ', ' duda '], code: 'PL' },
119
+ // Suiza
120
+ { keywords: [' switzerland ', ' swiss ', ' zurich ', ' geneva ', ' franc '], code: 'CH' },
121
+ // Suecia
122
+ { keywords: [' sweden ', ' swedish ', ' stockholm '], code: 'SE' },
123
+ // Noruega
124
+ { keywords: [' norway ', ' norwegian ', ' oslo '], code: 'NO' },
125
+ // Dinamarca
126
+ { keywords: [' denmark ', ' danish ', ' copenhagen '], code: 'DK' },
127
+ // Finlandia
128
+ { keywords: [' finland ', ' finnish ', ' helsinki '], code: 'FI' },
129
+ // Grecia
130
+ { keywords: [' greece ', ' greek ', ' athens '], code: 'GR' },
131
+ // Portugal
132
+ { keywords: [' portugal ', ' portuguese ', ' lisbon '], code: 'PT' },
133
+ // Bélgica
134
+ { keywords: [' belgium ', ' belgian ', ' brussels '], code: 'BE' },
135
+ // Austria
136
+ { keywords: [' austria ', ' austrian ', ' vienna '], code: 'AT' },
137
+ // Irlanda
138
+ { keywords: [' ireland ', ' irish ', ' dublin '], code: 'IE' },
139
+ // Pakistán
140
+ { keywords: [' pakistan ', ' pakistani ', ' islamabad '], code: 'PK' },
141
+ // Bangladés
142
+ { keywords: [' bangladesh ', ' bangladeshi ', ' dhaka '], code: 'BD' },
143
+ // Indonesia
144
+ { keywords: [' indonesia ', ' indonesian ', ' jakarta '], code: 'ID' },
145
+ // Filipinas
146
+ { keywords: [' philippines ', ' filipino ', ' manila '], code: 'PH' },
147
+ // Vietnam
148
+ { keywords: [' vietnam ', ' vietnamese ', ' hanoi '], code: 'VN' },
149
+ // Tailandia
150
+ { keywords: [' thailand ', ' thai ', ' bangkok '], code: 'TH' },
151
+ // Malasia
152
+ { keywords: [' malaysia ', ' malaysian ', ' kuala lumpur '], code: 'MY' },
153
+ // Singapur
154
+ { keywords: [' singapore ', ' singaporean '], code: 'SG' },
155
+ // Colombia
156
+ { keywords: [' colombia ', ' colombian ', ' bogotá '], code: 'CO' },
157
+ // Chile
158
+ { keywords: [' chile ', ' chilean ', ' santiago '], code: 'CL' },
159
+ // Perú
160
+ { keywords: [' peru ', ' peruvian ', ' lima '], code: 'PE' },
161
+ // Venezuela
162
+ { keywords: [' venezuela ', ' venezuelan ', ' caracas ', ' maduro '], code: 'VE' },
163
+ // Ecuador
164
+ { keywords: [' ecuador ', ' ecuadorian ', ' quito '], code: 'EC' },
165
+ // Nigeria
166
+ { keywords: [' nigeria ', ' nigerian ', ' lagos ', ' abuja '], code: 'NG' },
167
+ // Egipto
168
+ { keywords: [' egypt ', ' egyptian ', ' cairo '], code: 'EG' },
169
+ // Etiopía
170
+ { keywords: [' ethiopia ', ' ethiopian ', ' addis ababa '], code: 'ET' },
171
+ // Kenia
172
+ { keywords: [' kenya ', ' kenyan ', ' nairobi '], code: 'KE' },
173
+ // Cuba
174
+ { keywords: [' cuba ', ' cuban ', ' havana '], code: 'CU' },
175
+ // República Dominicana
176
+ { keywords: [' dominican republic ', ' dominican '], code: 'DO' },
177
+ // Curazao
178
+ { keywords: [' curaçao ', ' curacao '], code: 'CW' },
179
+ ];
180
+
181
+ for (const rule of rules) {
182
+ if (rule.keywords.some((kw) => text.includes(kw))) {
183
+ return rule.code;
184
+ }
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
  export function mapMarket(raw) {
191
  const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
192
+ const eventTitle = raw.events?.[0]?.title || '';
193
+ const question = raw.question || '';
194
+
195
  return {
196
  id: String(raw.id),
197
+ question,
198
+ category: inferCategory(question, eventTitle),
199
+ countryCode: inferCountryCode(question, eventTitle),
200
  yesPrice,
201
  noPrice,
202
  volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
backend/src/signals/aiPipeline.js CHANGED
@@ -29,7 +29,7 @@ const QWEN_MODEL = 'Qwen/Qwen3-8B';
29
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
30
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat-v3-0324:free';
31
 
32
- // Cached Gradio clients for Spaces
33
  let modernFinBERTClient = null;
34
  let qwenClient = null;
35
 
@@ -41,7 +41,7 @@ function hfHeaders() {
41
 
42
  function extractJson(text) {
43
  try { return JSON.parse(text); } catch {}
44
- // strip <think>...</think> blocks (Qwen3 thinking mode)
45
  const stripped = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
46
  try { return JSON.parse(stripped); } catch {}
47
  const block = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -92,7 +92,7 @@ async function filterWithFinBERTSpace(articles) {
92
 
93
  const textBlock = articles.map((a) => a.headline).join('\n');
94
  const result = await client.predict('/predict', { text_block: textBlock });
95
- // Gradio returns: { data: [ [ {label, score}, ... ] ], ... }
96
  const sentiments = result.data[0];
97
  return articles.filter((_, i) => {
98
  const r = sentiments[i];
@@ -110,7 +110,7 @@ async function filterWithFinBERTDirect(articles) {
110
  { inputs },
111
  { headers: hfHeaders(), retries: 1 },
112
  );
113
- // keep headlines classified as positive or negative with score >= 0.65
114
  return articles.filter((_, i) => {
115
  const top = results[i]?.[0];
116
  return top && top.label !== 'neutral' && top.score >= 0.65;
@@ -118,7 +118,7 @@ async function filterWithFinBERTDirect(articles) {
118
  }
119
 
120
  async function filterWithFinBERT(articles) {
121
- // Prefer Space if configured, fallback to direct API
122
  if (config.HF_SPACE_MODERNFINBERT_URL) {
123
  try {
124
  return await filterWithFinBERTSpace(articles);
@@ -226,7 +226,7 @@ function validateSignal(result) {
226
 
227
  function normalizeSignal(result) {
228
  if (!result) return result;
229
- // Normalize buy/sell/hold to bullish/bearish/neutral for downstream compatibility
230
  const signalMap = { buy: 'bullish', sell: 'bearish', hold: 'neutral' };
231
  if (result.signal in signalMap) {
232
  result.signal = signalMap[result.signal];
@@ -237,7 +237,7 @@ function normalizeSignal(result) {
237
  // ── public API ────────────────────────────────────────────────────────────────
238
 
239
  export async function run(market) {
240
- // Step 1: fetch and filter news
241
  let headlines = [];
242
  try {
243
  const allNews = await fetchFinancialNews(30);
@@ -251,10 +251,10 @@ export async function run(market) {
251
  logger.warn({ err: err.message, marketId: market.id }, 'news fetch failed, continuing without news');
252
  }
253
 
254
- // Step 2: LLM signal generation with fallback chain
255
  let result = null;
256
 
257
- // Try Space first
258
  if (config.HF_SPACE_QWEN_URL) {
259
  try {
260
  result = await generateWithQwenSpace(market, headlines);
@@ -265,7 +265,7 @@ export async function run(market) {
265
  }
266
  }
267
 
268
- // Fallback to direct HF API
269
  if (!result && config.HF_TOKEN) {
270
  try {
271
  result = await generateWithQwen3Direct(market, headlines);
@@ -275,7 +275,7 @@ export async function run(market) {
275
  }
276
  }
277
 
278
- // Fallback to OpenRouter
279
  if (!result && config.OPENROUTER_API_KEY) {
280
  try {
281
  result = await generateWithOpenRouter(market, headlines);
@@ -285,7 +285,7 @@ export async function run(market) {
285
  }
286
  }
287
 
288
- // Final fallback
289
  if (!result) {
290
  result = ruleBasedSignal(market);
291
  }
 
29
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
30
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat-v3-0324:free';
31
 
32
+ // Clientes Gradio en cache para Spaces
33
  let modernFinBERTClient = null;
34
  let qwenClient = null;
35
 
 
41
 
42
  function extractJson(text) {
43
  try { return JSON.parse(text); } catch {}
44
+ // elimina bloques <think>...</think> (modo thinking de Qwen3)
45
  const stripped = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
46
  try { return JSON.parse(stripped); } catch {}
47
  const block = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
 
92
 
93
  const textBlock = articles.map((a) => a.headline).join('\n');
94
  const result = await client.predict('/predict', { text_block: textBlock });
95
+ // Gradio devuelve: { data: [ [ {label, score}, ... ] ], ... }
96
  const sentiments = result.data[0];
97
  return articles.filter((_, i) => {
98
  const r = sentiments[i];
 
110
  { inputs },
111
  { headers: hfHeaders(), retries: 1 },
112
  );
113
+ // conserva titulares clasificados como positivos o negativos con score >= 0.65
114
  return articles.filter((_, i) => {
115
  const top = results[i]?.[0];
116
  return top && top.label !== 'neutral' && top.score >= 0.65;
 
118
  }
119
 
120
  async function filterWithFinBERT(articles) {
121
+ // Prefiere Space si esta configurado, respaldo a API directa
122
  if (config.HF_SPACE_MODERNFINBERT_URL) {
123
  try {
124
  return await filterWithFinBERTSpace(articles);
 
226
 
227
  function normalizeSignal(result) {
228
  if (!result) return result;
229
+ // Normaliza buy/sell/hold a bullish/bearish/neutral para compatibilidad descendente
230
  const signalMap = { buy: 'bullish', sell: 'bearish', hold: 'neutral' };
231
  if (result.signal in signalMap) {
232
  result.signal = signalMap[result.signal];
 
237
  // ── public API ────────────────────────────────────────────────────────────────
238
 
239
  export async function run(market) {
240
+ // Paso 1: obtiene y filtra noticias
241
  let headlines = [];
242
  try {
243
  const allNews = await fetchFinancialNews(30);
 
251
  logger.warn({ err: err.message, marketId: market.id }, 'news fetch failed, continuing without news');
252
  }
253
 
254
+ // Paso 2: generacion de senal LLM con cadena de respaldo
255
  let result = null;
256
 
257
+ // Intenta Space primero
258
  if (config.HF_SPACE_QWEN_URL) {
259
  try {
260
  result = await generateWithQwenSpace(market, headlines);
 
265
  }
266
  }
267
 
268
+ // Respaldo a API directa de HF
269
  if (!result && config.HF_TOKEN) {
270
  try {
271
  result = await generateWithQwen3Direct(market, headlines);
 
275
  }
276
  }
277
 
278
+ // Respaldo a OpenRouter
279
  if (!result && config.OPENROUTER_API_KEY) {
280
  try {
281
  result = await generateWithOpenRouter(market, headlines);
 
285
  }
286
  }
287
 
288
+ // Respaldo final
289
  if (!result) {
290
  result = ruleBasedSignal(market);
291
  }
backend/src/signals/signals.controller.js CHANGED
@@ -20,4 +20,11 @@ export const signalsController = {
20
  const signal = await signalsService.getLatest(req.params.marketId);
21
  ok(res, signal);
22
  },
 
 
 
 
 
 
 
23
  };
 
20
  const signal = await signalsService.getLatest(req.params.marketId);
21
  ok(res, signal);
22
  },
23
+
24
+ async getLatestBatch(req, res) {
25
+ const ids = req.query.marketIds;
26
+ const marketIds = ids ? ids.split(',').filter(Boolean) : [];
27
+ const signals = await signalsService.getLatestBatch(marketIds);
28
+ ok(res, signals);
29
+ },
30
  };
backend/src/signals/signals.repository.js CHANGED
@@ -26,4 +26,20 @@ export const signalsRepository = {
26
  orderBy: { generatedAt: 'desc' },
27
  });
28
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  };
 
26
  orderBy: { generatedAt: 'desc' },
27
  });
28
  },
29
+
30
+ findLatestForMarkets(marketIds) {
31
+ if (!marketIds || marketIds.length === 0) return Promise.resolve([]);
32
+ return prisma.aISignal.findMany({
33
+ where: { marketId: { in: marketIds } },
34
+ orderBy: [{ marketId: 'asc' }, { generatedAt: 'desc' }],
35
+ }).then((rows) => {
36
+ // Tomar solo la más reciente por marketId
37
+ const seen = new Set();
38
+ return rows.filter((r) => {
39
+ if (seen.has(r.marketId)) return false;
40
+ seen.add(r.marketId);
41
+ return true;
42
+ });
43
+ });
44
+ },
45
  };
backend/src/signals/signals.routes.js CHANGED
@@ -12,6 +12,10 @@ import { signalsController } from './signals.controller.js';
12
 
13
  const router = Router();
14
 
 
 
 
 
15
  // mounted at /api/v1/markets → full path: GET /api/v1/markets/:marketId/signal
16
  router.get('/:marketId/signal', signalsController.getLatest);
17
 
 
12
 
13
  const router = Router();
14
 
15
+ // Batch: últimas señales para múltiples mercados (debe ir ANTES de la ruta dinámica)
16
+ // mounted at /api/v1/markets → full path: GET /api/v1/markets/signals/latest?marketIds=id1,id2
17
+ router.get('/signals/latest', signalsController.getLatestBatch);
18
+
19
  // mounted at /api/v1/markets → full path: GET /api/v1/markets/:marketId/signal
20
  router.get('/:marketId/signal', signalsController.getLatest);
21
 
backend/src/signals/signals.service.js CHANGED
@@ -32,6 +32,12 @@ export const signalsService = {
32
  return signal;
33
  },
34
 
 
 
 
 
 
 
35
  async generateForMarket(market) {
36
  const modelVersion = 'Qwen3-8B';
37
  const result = await aiPipeline.run(market);
 
32
  return signal;
33
  },
34
 
35
+ async getLatestBatch(marketIds) {
36
+ if (!marketIds || marketIds.length === 0) return [];
37
+ const signals = await signalsRepository.findLatestForMarkets(marketIds);
38
+ return signals;
39
+ },
40
+
41
  async generateForMarket(market) {
42
  const modelVersion = 'Qwen3-8B';
43
  const result = await aiPipeline.run(market);
backend/src/stats/stats.routes.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Rutas REST de estadisticas globales (publicas).
3
+ *
4
+ * Endpoint (montado en /api/v1/stats):
5
+ * GET / → estadisticas agregadas del sistema.
6
+ *
7
+ * Datos expuestos:
8
+ * - marketsCount : total de mercados activos.
9
+ * - volume24h : suma de volumen de mercados activos.
10
+ * - signalsCount : total de senales IA generadas.
11
+ * - alertsToday : alertas enviadas en las ultimas 24h.
12
+ */
13
+
14
+ import { Router } from 'express';
15
+ import { prisma } from '../utils/prisma.js';
16
+ import { ok } from '../utils/apiResponse.js';
17
+
18
+ const router = Router();
19
+
20
+ router.get('/', async (_req, res) => {
21
+ const [marketsCount, signalsCount, alertsToday] = await Promise.all([
22
+ prisma.market.count({ where: { status: 'active' } }),
23
+ prisma.aISignal.count(),
24
+ prisma.alert.count({
25
+ where: { sentAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
26
+ }),
27
+ ]);
28
+
29
+ const volumeAgg = await prisma.market.aggregate({
30
+ where: { status: 'active' },
31
+ _sum: { volumeEur: true },
32
+ });
33
+
34
+ ok(res, {
35
+ marketsCount,
36
+ volume24h: volumeAgg._sum.volumeEur || 0,
37
+ signalsCount,
38
+ alertsToday,
39
+ });
40
+ });
41
+
42
+ export default router;
frontend/dist/assets/index-B7Y-b3uS.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-DUFVKRJi.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal — Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
- <script type="module" crossorigin src="/assets/index-DUFVKRJi.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-xtYPhhTl.css">
10
  </head>
11
  <body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal — Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
+ <script type="module" crossorigin src="/assets/index-B7Y-b3uS.js"></script>
9
  <link rel="stylesheet" crossorigin href="/assets/index-xtYPhhTl.css">
10
  </head>
11
  <body>
frontend/src/api.js CHANGED
@@ -3,10 +3,13 @@
3
  *
4
  * Responsabilidades:
5
  * - Realizar peticiones fetch a /api/v1/* con headers JSON.
 
 
6
  * - Gestionar errores HTTP (lanza Error con status y body).
7
  * - Manejar 204 No Content (retorna null).
8
  *
9
  * Modulos cubiertos:
 
10
  * - Markets → getMarkets(params), getMarket(id)
11
  * - Signals → getSignal(marketId)
12
  * - Positions → getPositions(), createPosition(data), closePosition(id)
@@ -18,18 +21,71 @@
18
  */
19
 
20
  const BASE = '/api/v1'
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  async function fetchJson(url, opts = {}) {
 
 
 
 
 
 
 
 
 
23
  const res = await fetch(url, {
24
- headers: { 'Content-Type': 'application/json', ...opts.headers },
25
  ...opts,
26
  })
 
27
  if (!res.ok) {
28
  const text = await res.text().catch(() => '')
29
  throw new Error(`HTTP ${res.status}: ${text}`)
30
  }
 
31
  if (res.status === 204) return null
32
- return res.json()
 
 
 
 
 
 
33
  }
34
 
35
  /* ─── Markets ─── */
@@ -47,6 +103,12 @@ export async function getSignal(marketId) {
47
  return fetchJson(`${BASE}/markets/${marketId}/signal`)
48
  }
49
 
 
 
 
 
 
 
50
  /* ─── Positions ─── */
51
  export async function getPositions() {
52
  return fetchJson(`${BASE}/positions`)
 
3
  *
4
  * Responsabilidades:
5
  * - Realizar peticiones fetch a /api/v1/* con headers JSON.
6
+ * - Gestionar token JWT (login, logout, Authorization header).
7
+ * - Extraer el campo `data` de las respuestas estandarizadas del backend.
8
  * - Gestionar errores HTTP (lanza Error con status y body).
9
  * - Manejar 204 No Content (retorna null).
10
  *
11
  * Modulos cubiertos:
12
+ * - Auth → login(email, password), logout()
13
  * - Markets → getMarkets(params), getMarket(id)
14
  * - Signals → getSignal(marketId)
15
  * - Positions → getPositions(), createPosition(data), closePosition(id)
 
21
  */
22
 
23
  const BASE = '/api/v1'
24
+ const TOKEN_KEY = 'polysignal_token'
25
 
26
+ /* ─── Token helpers ─── */
27
+ export function getToken() {
28
+ return localStorage.getItem(TOKEN_KEY)
29
+ }
30
+
31
+ export function setToken(token) {
32
+ localStorage.setItem(TOKEN_KEY, token)
33
+ }
34
+
35
+ export function clearToken() {
36
+ localStorage.removeItem(TOKEN_KEY)
37
+ }
38
+
39
+ export function isAuthenticated() {
40
+ return !!getToken()
41
+ }
42
+
43
+ /* ─── Auth ─── */
44
+ export async function login(email, password) {
45
+ const body = await fetchJson(`${BASE}/auth/login`, {
46
+ method: 'POST',
47
+ body: JSON.stringify({ email, password }),
48
+ skipAuth: true,
49
+ })
50
+ if (body.token) {
51
+ setToken(body.token)
52
+ }
53
+ return body
54
+ }
55
+
56
+ export function logout() {
57
+ clearToken()
58
+ }
59
+
60
+ /* ─── Core fetch ─── */
61
  async function fetchJson(url, opts = {}) {
62
+ const headers = { 'Content-Type': 'application/json', ...opts.headers }
63
+
64
+ if (!opts.skipAuth) {
65
+ const token = getToken()
66
+ if (token) {
67
+ headers.Authorization = `Bearer ${token}`
68
+ }
69
+ }
70
+
71
  const res = await fetch(url, {
72
+ headers,
73
  ...opts,
74
  })
75
+
76
  if (!res.ok) {
77
  const text = await res.text().catch(() => '')
78
  throw new Error(`HTTP ${res.status}: ${text}`)
79
  }
80
+
81
  if (res.status === 204) return null
82
+
83
+ const body = await res.json()
84
+ // El backend envuelve respuestas exitosas en { ok: true, data: ... }
85
+ if (body && body.ok === true && 'data' in body) {
86
+ return body.data
87
+ }
88
+ return body
89
  }
90
 
91
  /* ─── Markets ─── */
 
103
  return fetchJson(`${BASE}/markets/${marketId}/signal`)
104
  }
105
 
106
+ export async function getSignalsBatch(marketIds) {
107
+ if (!marketIds || marketIds.length === 0) return []
108
+ const qs = new URLSearchParams({ marketIds: marketIds.join(',') }).toString()
109
+ return fetchJson(`${BASE}/markets/signals/latest?${qs}`)
110
+ }
111
+
112
  /* ─── Positions ─── */
113
  export async function getPositions() {
114
  return fetchJson(`${BASE}/positions`)
frontend/src/app.js CHANGED
@@ -11,10 +11,10 @@
11
  * * price_alert → anade alertas al historial.
12
  * - Renderizado del panel de detalle de mercado con sparklines, grafico 7d,
13
  * analisis IA y simulador de posiciones virtuales.
14
- * - Fallback a datos mock si el backend no responde (modo demo).
15
  *
16
  * Modulos importados:
17
- * - api.js → cliente REST del backend.
18
  * - charts.js → Chart.js (historial 7d + sparklines).
19
  * - map.js → Leaflet (mapa mundial interactivo).
20
  * - simulator.js → logica de compra/venta virtual.
@@ -44,175 +44,20 @@ let state = {
44
  sidebarCollapsed: false,
45
  }
46
 
47
- /* ─── Datos mock para demo (fallback si backend no responde) ─── */
48
- const MOCK_MARKETS = [
49
- {
50
- id: 'usa-001',
51
- question: '¿Trump firmará la ley fiscal antes de junio de 2026?',
52
- category: 'política',
53
- countryCode: 'US',
54
- yesPrice: 0.73,
55
- noPrice: 0.27,
56
- volumeEur: 1240000,
57
- liquidityEur: 340000,
58
- status: 'active',
59
- closesAt: '2026-06-01T00:00:00Z',
60
- },
61
- {
62
- id: 'eur-001',
63
- question: '¿El BCE recortará tipos en junio de 2026?',
64
- category: 'economía',
65
- countryCode: 'DE',
66
- yesPrice: 0.34,
67
- noPrice: 0.66,
68
- volumeEur: 890000,
69
- liquidityEur: 210000,
70
- status: 'active',
71
- closesAt: '2026-06-12T00:00:00Z',
72
- },
73
- {
74
- id: 'chn-001',
75
- question: '¿Se alcanzará un acuerdo arancelario EE.UU.-China antes del T3 2026?',
76
- category: 'comercio',
77
- countryCode: 'CN',
78
- yesPrice: 0.51,
79
- noPrice: 0.49,
80
- volumeEur: 2100000,
81
- liquidityEur: 580000,
82
- status: 'active',
83
- closesAt: '2026-07-01T00:00:00Z',
84
- },
85
- {
86
- id: 'bra-001',
87
- question: '¿Bitcoin superará los $120.000 antes de julio de 2026?',
88
- category: 'cripto',
89
- countryCode: 'BR',
90
- yesPrice: 0.61,
91
- noPrice: 0.39,
92
- volumeEur: 3400000,
93
- liquidityEur: 920000,
94
- status: 'active',
95
- closesAt: '2026-07-01T00:00:00Z',
96
- },
97
- {
98
- id: 'uk-001',
99
- question: '¿La inflación del Reino Unido bajará del 2% en 2026?',
100
- category: 'economía',
101
- countryCode: 'GB',
102
- yesPrice: 0.45,
103
- noPrice: 0.55,
104
- volumeEur: 620000,
105
- liquidityEur: 180000,
106
- status: 'active',
107
- closesAt: '2026-09-01T00:00:00Z',
108
- },
109
- {
110
- id: 'ind-001',
111
- question: '¿El crecimiento del PIB de India superará el 7% en el AF2026?',
112
- category: 'economía',
113
- countryCode: 'IN',
114
- yesPrice: 0.68,
115
- noPrice: 0.32,
116
- volumeEur: 450000,
117
- liquidityEur: 120000,
118
- status: 'active',
119
- closesAt: '2026-03-31T00:00:00Z',
120
- },
121
- ]
122
-
123
- const MOCK_SIGNALS = {
124
- 'usa-001': {
125
- signal: 'bullish',
126
- confidence: 0.87,
127
- summary: 'El liderazgo republicano confirmó 3 de 4 votos de comité asegurados. El sentimiento de noticias de 8 fuentes es positivo (+0,72). La línea temporal del Congreso sugiere que la ventana de firma se abre del 28 al 31 de mayo.',
128
- keyRisk: 'El proceso de enmiendas en el Senado podría retrasar más allá del 1 de junio.',
129
- },
130
- 'eur-001': {
131
- signal: 'bearish',
132
- confidence: 0.74,
133
- summary: 'La inflación de la zona euro sorprendió al alza al 2,4% en abril. Las recientes declaraciones de los miembros del consejo de gobierno del BCE señalan que es más probable una pausa.',
134
- keyRisk: 'La valoración del mercado de bonos implica solo un 22% de probabilidad de recorte en junio.',
135
- },
136
- 'chn-001': {
137
- signal: 'neutral',
138
- confidence: 0.52,
139
- summary: 'Las negociaciones continúan pero no se ha acordado un marco formal. Los flujos comerciales muestran patrones tempranos de evasión arancelaria, sugiriendo que ambas partes ganan tiempo.',
140
- keyRisk: 'Demasiadas variables geopolíticas. Se monitorea la declaración del USTR como señal clave.',
141
- },
142
- 'bra-001': {
143
- signal: 'bullish',
144
- confidence: 0.79,
145
- summary: 'BTC actualmente en $103.400. Las métricas on-chain muestran acumulación de grandes carteras. Entradas en ETF +$820M esta semana. El mercado de opciones implica 68% de probabilidad de $120K a finales de junio.',
146
- keyRisk: 'Las condiciones de liquidez macro podrían cambiar si la Fed mantiene una postura hawkish.',
147
- },
148
- 'uk-001': {
149
- signal: 'neutral',
150
- confidence: 0.58,
151
- summary: 'Las previsiones del BoE sugieren una caída gradual pero los shocks externos de precios de energía siguen siendo una incógnita. La inflación de servicios se mantiene en el 5,2%.',
152
- keyRisk: 'Las interrupciones geopolíticas en la cadena de suministro podrían reavivar la inflación de bienes.',
153
- },
154
- 'ind-001': {
155
- signal: 'bullish',
156
- confidence: 0.71,
157
- summary: 'Dato del T4 AF25 en 7,1% con expansión del PMI manufacturero. El impulso del gasto público en infraestructura probablemente sostenga el momentum en el S1 AF26.',
158
- keyRisk: 'El impacto de El Niño en la producción agrícola podría reducir 30-40 pb del dato principal.',
159
- },
160
- }
161
-
162
- const MOCK_POSITIONS = [
163
- {
164
- id: 1,
165
- marketId: 'usa-001',
166
- outcome: 'SÍ',
167
- amountEur: 100,
168
- entryPrice: 0.68,
169
- currentPrice: 0.73,
170
- pnl: 14.20,
171
- kellyFraction: 0.25,
172
- openedAt: '2026-05-10T14:30:00Z',
173
- },
174
- {
175
- id: 2,
176
- marketId: 'eur-001',
177
- outcome: 'NO',
178
- amountEur: 80,
179
- entryPrice: 0.62,
180
- currentPrice: 0.66,
181
- pnl: -3.80,
182
- kellyFraction: 0.18,
183
- openedAt: '2026-05-11T09:15:00Z',
184
- },
185
- ]
186
-
187
- const MOCK_ALERTS = [
188
- {
189
- id: 1,
190
- marketId: 'usa-001',
191
- type: 'cambio_señal',
192
- message: 'La señal cambió a ALCISTA (87%) en ley fiscal de Trump',
193
- sentAt: '2026-05-14T08:23:00Z',
194
- },
195
- {
196
- id: 2,
197
- marketId: 'bra-001',
198
- type: 'umbral_precio',
199
- message: 'El mercado BTC $120K SÍ cruzó el umbral de 60¢',
200
- sentAt: '2026-05-14T07:45:00Z',
201
- },
202
- ]
203
-
204
  /* ─── Helpers ─── */
205
  function formatCurrency(n) {
 
206
  if (n >= 1e6) return '€' + (n / 1e6).toFixed(1) + 'M'
207
  if (n >= 1e3) return '€' + (n / 1e3).toFixed(1) + 'K'
208
  return '€' + n.toFixed(0)
209
  }
210
 
211
  function formatPrice(p) {
212
- return Math.round(p * 100) + '¢'
213
  }
214
 
215
  function formatDate(iso) {
 
216
  const d = new Date(iso)
217
  return d.toLocaleDateString('es-ES', { month: 'short', day: 'numeric', year: 'numeric' })
218
  }
@@ -241,7 +86,13 @@ function translateSignal(signal) {
241
  return 'neutral'
242
  }
243
 
244
- // Creates an element with an optional class and optional text content
 
 
 
 
 
 
245
  function el(tag, className, text) {
246
  const node = document.createElement(tag)
247
  if (className) node.className = className
@@ -253,6 +104,18 @@ function emptyState(text, sm = false) {
253
  return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
254
  }
255
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  /* ─── Routing de vistas ─── */
257
  function switchView(viewName) {
258
  state.view = viewName
@@ -284,8 +147,13 @@ function renderSignals() {
284
  if (!container) return
285
  container.replaceChildren()
286
 
 
 
 
 
 
287
  state.markets.forEach((m) => {
288
- const sig = state.signals.find((s) => s.marketId === m.id) || MOCK_SIGNALS[m.id] || { signal: 'neutral', confidence: 0.5 }
289
  const cls = signalColorClass(sig.signal)
290
 
291
  const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
@@ -302,7 +170,7 @@ function renderSignals() {
302
  const probWrap = el('div', 'prob-bar-wrap')
303
  const probBg = el('div', 'prob-bar-bg')
304
  const probFill = el('div', `prob-bar-fill bg-${cls}`)
305
- probFill.style.setProperty('--prob-width', `${Math.round(m.yesPrice * 100)}%`)
306
  probBg.appendChild(probFill)
307
  probWrap.appendChild(probBg)
308
 
@@ -331,16 +199,16 @@ function renderMiniPositions() {
331
 
332
  let netPnl = 0
333
  state.positions.forEach((p) => {
334
- const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
335
- netPnl += p.pnl
336
- const cls = p.pnl >= 0 ? 'green' : 'red'
337
- const sign = p.pnl >= 0 ? '+' : ''
338
 
339
  const row = el('div', 'flex-between mb-6')
340
  const label = el('span', 'text-sm text-neutral font-mono')
341
- label.textContent = `${m.question.substring(0, 32)}${m.question.length > 32 ? '…' : ''} ${p.outcome}`
342
  const val = el('span', `text-base font-semibold text-${cls} font-mono`)
343
- val.textContent = `${sign}€${p.pnl.toFixed(2)}`
344
  row.append(label, val)
345
  container.appendChild(row)
346
  })
@@ -365,10 +233,15 @@ function renderDetail() {
365
  return
366
  }
367
 
368
- const sig = state.signals.find((s) => s.marketId === m.id) || MOCK_SIGNALS[m.id] || { signal: 'neutral', confidence: 0.5, summary: 'Aún no hay análisis de IA disponible.', keyRisk: '' }
 
 
 
 
 
369
  const delta = ((m.yesPrice - 0.5) * 20).toFixed(1)
370
- const deltaCls = m.yesPrice > 0.5 ? 'green' : 'red'
371
- const deltaSign = m.yesPrice > 0.5 ? '+' : ''
372
 
373
  // ── Header ──
374
  const tag = el('div', 'detail-tag')
@@ -402,7 +275,7 @@ function renderDetail() {
402
  const yesPriceEl = el('div', 'outcome-price')
403
  yesPriceEl.textContent = formatPrice(m.yesPrice)
404
  const yesDeltaEl = el('div', 'outcome-delta td-green')
405
- yesDeltaEl.textContent = `▲ ${(m.yesPrice * 0.05).toFixed(1)}¢`
406
  const yesCard = el('div', 'outcome-card yes')
407
  yesCard.append(el('div', 'outcome-name', 'SÍ'), yesPriceEl, yesDeltaEl, sparkYes)
408
 
@@ -411,7 +284,7 @@ function renderDetail() {
411
  const noPriceEl = el('div', 'outcome-price')
412
  noPriceEl.textContent = formatPrice(m.noPrice)
413
  const noDeltaEl = el('div', 'outcome-delta td-red')
414
- noDeltaEl.textContent = `▼ ${(m.noPrice * 0.05).toFixed(1)}¢`
415
  const noCard = el('div', 'outcome-card no')
416
  noCard.append(el('div', 'outcome-name', 'NO'), noPriceEl, noDeltaEl, sparkNo)
417
 
@@ -430,9 +303,9 @@ function renderDetail() {
430
  const aiHeader = el('div', 'flex-row gap-8 mb-4 flex-wrap')
431
  aiHeader.append(el('div', 'ai-label', 'Análisis IA · HuggingFace Qwen3-8B'), aiBadge, aiMeta)
432
 
433
- // AI text built with DOM nodes — no external string ever touches innerHTML
434
  const aiText = el('div', 'ai-text')
435
- aiText.textContent = sig.summary
436
  if (sig.keyRisk) {
437
  const strong = document.createElement('strong')
438
  strong.textContent = 'Riesgo clave:'
@@ -468,11 +341,11 @@ function renderDetail() {
468
 
469
  container.replaceChildren(header, outcomesRow, aiBox, simRow)
470
 
471
- // Bind simulator buttons
472
- simYes.addEventListener('click', () => simulator.openPosition(m.id, 'SÍ', simAmount.value))
473
  simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
474
 
475
- // Render charts
476
  charts.renderDetailChart('detail-chart', m.yesPrice)
477
  charts.renderSparkline('spark-yes', m.yesPrice, 'yes')
478
  charts.renderSparkline('spark-no', m.noPrice, 'no')
@@ -500,17 +373,17 @@ function renderPositions() {
500
  tbody.replaceChildren()
501
 
502
  state.positions.forEach((p) => {
503
- const m = state.markets.find((x) => x.id === p.marketId) || { question: p.marketId }
504
- const pnlColor = p.pnl >= 0 ? 'td-green' : 'td-red'
505
- const sign = p.pnl >= 0 ? '+' : ''
506
 
507
  const tr = document.createElement('tr')
508
 
509
  const tdQ = el('td')
510
- tdQ.textContent = `${m.question.substring(0, 40)}${m.question.length > 40 ? '…' : ''}`
511
 
512
- const tdOutcome = el('td', `td-mono ${p.outcome === 'SÍ' ? 'td-green' : 'td-red'}`)
513
- tdOutcome.textContent = p.outcome
514
 
515
  const tdAmt = el('td', 'td-mono')
516
  tdAmt.textContent = `€${p.amountEur.toFixed(0)}`
@@ -522,7 +395,7 @@ function renderPositions() {
522
  tdCurrent.textContent = formatPrice(p.currentPrice)
523
 
524
  const tdPnl = el('td', `td-mono ${pnlColor}`)
525
- tdPnl.textContent = `${sign}€${p.pnl.toFixed(2)}`
526
 
527
  const tdKelly = el('td', 'td-mono td-blue')
528
  tdKelly.textContent = `${((p.kellyFraction || 0) * 100).toFixed(0)}%`
@@ -561,13 +434,13 @@ function renderWatchlist() {
561
  tbody.replaceChildren()
562
 
563
  state.watchlist.forEach((w) => {
564
- const m = state.markets.find((x) => x.id === w.marketId) || { question: w.marketId, category: '-', yesPrice: 0, noPrice: 0, volumeEur: 0 }
565
  const sig = state.signals.find((s) => s.marketId === w.marketId) || { signal: 'neutral' }
566
 
567
  const tr = document.createElement('tr')
568
 
569
  const tdQ = el('td')
570
- tdQ.textContent = `${m.question.substring(0, 40)}${m.question.length > 40 ? '…' : ''}`
571
 
572
  const tdCat = el('td')
573
  tdCat.textContent = m.category || '-'
@@ -619,7 +492,7 @@ function renderAlerts() {
619
  tbody.replaceChildren()
620
 
621
  state.alerts.forEach((a) => {
622
- const m = state.markets.find((x) => x.id === a.marketId) || { question: a.marketId }
623
 
624
  const tr = document.createElement('tr')
625
 
@@ -627,7 +500,7 @@ function renderAlerts() {
627
  tdDate.textContent = new Date(a.sentAt).toLocaleString('es-ES')
628
 
629
  const tdQ = el('td')
630
- tdQ.textContent = `${m.question.substring(0, 35)}${m.question.length > 35 ? '…' : ''}`
631
 
632
  const typeBadge = el('span', 'signal-badge sig-neut')
633
  typeBadge.textContent = a.type
@@ -645,22 +518,25 @@ function renderAlerts() {
645
  /* ─── Carga de datos ─── */
646
  async function loadMarkets() {
647
  try {
648
- const data = await api.getMarkets({ limit: 50 })
649
- state.markets = data.length ? data : MOCK_MARKETS
650
  } catch (e) {
651
- console.warn('API mercados no disponible, usando datos de prueba')
652
- state.markets = MOCK_MARKETS
653
  }
654
  }
655
 
656
  async function loadSignals() {
 
 
 
 
657
  try {
658
- const promises = state.markets.map((m) => api.getSignal(m.id).catch(() => null))
659
- const results = await Promise.all(promises)
660
- state.signals = results.filter(Boolean).map((r, i) => ({ ...r, marketId: state.markets[i].id }))
661
  } catch (e) {
662
- console.warn('API señales no disponible, usando datos de prueba')
663
- state.signals = Object.entries(MOCK_SIGNALS).map(([marketId, s]) => ({ ...s, marketId }))
664
  }
665
  }
666
 
@@ -668,7 +544,8 @@ async function loadPositions() {
668
  try {
669
  state.positions = await api.getPositions()
670
  } catch (e) {
671
- state.positions = MOCK_POSITIONS
 
672
  }
673
  }
674
 
@@ -676,6 +553,7 @@ async function loadWatchlist() {
676
  try {
677
  state.watchlist = await api.getWatchlist()
678
  } catch (e) {
 
679
  state.watchlist = []
680
  }
681
  }
@@ -684,7 +562,24 @@ async function loadAlerts() {
684
  try {
685
  state.alerts = await api.getAlerts()
686
  } catch (e) {
687
- state.alerts = MOCK_ALERTS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  }
689
  }
690
 
@@ -703,11 +598,15 @@ export async function init() {
703
  })
704
  })
705
 
 
 
 
706
  await loadMarkets()
707
  await loadSignals()
708
  await loadPositions()
709
  await loadWatchlist()
710
  await loadAlerts()
 
711
 
712
  map.init('map-container', state.markets, state.signals, selectMarket)
713
  simulator.init(state)
@@ -723,7 +622,7 @@ export async function init() {
723
  socket.on('market_update', (data) => {
724
  const m = state.markets.find((x) => x.id === data.marketId)
725
  if (m) {
726
- // Only copy known numeric fields — never merge the whole payload
727
  if (typeof data.yesPrice === 'number') m.yesPrice = data.yesPrice
728
  if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
729
  if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
@@ -748,16 +647,4 @@ export async function init() {
748
  state.alerts.unshift(data)
749
  if (state.view === 'alerts') renderAlerts()
750
  })
751
-
752
- setInterval(() => {
753
- const statMarkets = document.getElementById('stat-markets')
754
- if (statMarkets) {
755
- const n = parseInt(statMarkets.textContent.replace(/\./g, '')) + Math.floor(Math.random() * 3)
756
- statMarkets.textContent = n.toLocaleString('es-ES')
757
- }
758
- const statSignals = document.getElementById('stat-signals')
759
- if (statSignals && Math.random() > 0.7) {
760
- statSignals.textContent = parseInt(statSignals.textContent) + 1
761
- }
762
- }, 3000)
763
  }
 
11
  * * price_alert → anade alertas al historial.
12
  * - Renderizado del panel de detalle de mercado con sparklines, grafico 7d,
13
  * analisis IA y simulador de posiciones virtuales.
14
+ * - Auto-login con credenciales demo para endpoints protegidos.
15
  *
16
  * Modulos importados:
17
+ * - api.js → cliente REST del backend (con JWT).
18
  * - charts.js → Chart.js (historial 7d + sparklines).
19
  * - map.js → Leaflet (mapa mundial interactivo).
20
  * - simulator.js → logica de compra/venta virtual.
 
44
  sidebarCollapsed: false,
45
  }
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  /* ─── Helpers ─── */
48
  function formatCurrency(n) {
49
+ if (!n || n === 0) return '€0'
50
  if (n >= 1e6) return '€' + (n / 1e6).toFixed(1) + 'M'
51
  if (n >= 1e3) return '€' + (n / 1e3).toFixed(1) + 'K'
52
  return '€' + n.toFixed(0)
53
  }
54
 
55
  function formatPrice(p) {
56
+ return Math.round((p || 0) * 100) + '¢'
57
  }
58
 
59
  function formatDate(iso) {
60
+ if (!iso) return '—'
61
  const d = new Date(iso)
62
  return d.toLocaleDateString('es-ES', { month: 'short', day: 'numeric', year: 'numeric' })
63
  }
 
86
  return 'neutral'
87
  }
88
 
89
+ function translateOutcome(outcome) {
90
+ if (outcome === 'YES') return 'SÍ'
91
+ if (outcome === 'NO') return 'NO'
92
+ return outcome
93
+ }
94
+
95
+ // Crea un elemento con una clase opcional y contenido de texto opcional
96
  function el(tag, className, text) {
97
  const node = document.createElement(tag)
98
  if (className) node.className = className
 
104
  return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
105
  }
106
 
107
+ /* ─── Auth ─── */
108
+ async function ensureAuth() {
109
+ if (api.isAuthenticated()) return true
110
+ try {
111
+ await api.login('user@polysignal.test', 'User123!')
112
+ return true
113
+ } catch (e) {
114
+ console.warn('Auto-login fallido:', e)
115
+ return false
116
+ }
117
+ }
118
+
119
  /* ─── Routing de vistas ─── */
120
  function switchView(viewName) {
121
  state.view = viewName
 
147
  if (!container) return
148
  container.replaceChildren()
149
 
150
+ if (state.markets.length === 0) {
151
+ container.appendChild(emptyState('No hay mercados disponibles'))
152
+ return
153
+ }
154
+
155
  state.markets.forEach((m) => {
156
+ const sig = state.signals.find((s) => s.marketId === m.id) || { signal: 'neutral', confidence: 0.5 }
157
  const cls = signalColorClass(sig.signal)
158
 
159
  const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
 
170
  const probWrap = el('div', 'prob-bar-wrap')
171
  const probBg = el('div', 'prob-bar-bg')
172
  const probFill = el('div', `prob-bar-fill bg-${cls}`)
173
+ probFill.style.setProperty('--prob-width', `${Math.round((m.yesPrice || 0) * 100)}%`)
174
  probBg.appendChild(probFill)
175
  probWrap.appendChild(probBg)
176
 
 
199
 
200
  let netPnl = 0
201
  state.positions.forEach((p) => {
202
+ const m = state.markets.find((x) => x.id === p.marketId) || p.market || { question: p.marketId }
203
+ netPnl += p.pnl || 0
204
+ const cls = (p.pnl || 0) >= 0 ? 'green' : 'red'
205
+ const sign = (p.pnl || 0) >= 0 ? '+' : ''
206
 
207
  const row = el('div', 'flex-between mb-6')
208
  const label = el('span', 'text-sm text-neutral font-mono')
209
+ label.textContent = `${(m.question || p.marketId).substring(0, 32)}${(m.question || p.marketId).length > 32 ? '…' : ''} ${translateOutcome(p.outcome)}`
210
  const val = el('span', `text-base font-semibold text-${cls} font-mono`)
211
+ val.textContent = `${sign}€${(p.pnl || 0).toFixed(2)}`
212
  row.append(label, val)
213
  container.appendChild(row)
214
  })
 
233
  return
234
  }
235
 
236
+ const sig = state.signals.find((s) => s.marketId === m.id) || {
237
+ signal: 'neutral',
238
+ confidence: 0.5,
239
+ summary: 'Aún no hay análisis de IA disponible.',
240
+ keyRisk: '',
241
+ }
242
  const delta = ((m.yesPrice - 0.5) * 20).toFixed(1)
243
+ const deltaCls = (m.yesPrice || 0) > 0.5 ? 'green' : 'red'
244
+ const deltaSign = (m.yesPrice || 0) > 0.5 ? '+' : ''
245
 
246
  // ── Header ──
247
  const tag = el('div', 'detail-tag')
 
275
  const yesPriceEl = el('div', 'outcome-price')
276
  yesPriceEl.textContent = formatPrice(m.yesPrice)
277
  const yesDeltaEl = el('div', 'outcome-delta td-green')
278
+ yesDeltaEl.textContent = `▲ ${((m.yesPrice || 0) * 0.05).toFixed(1)}¢`
279
  const yesCard = el('div', 'outcome-card yes')
280
  yesCard.append(el('div', 'outcome-name', 'SÍ'), yesPriceEl, yesDeltaEl, sparkYes)
281
 
 
284
  const noPriceEl = el('div', 'outcome-price')
285
  noPriceEl.textContent = formatPrice(m.noPrice)
286
  const noDeltaEl = el('div', 'outcome-delta td-red')
287
+ noDeltaEl.textContent = `▼ ${((m.noPrice || 0) * 0.05).toFixed(1)}¢`
288
  const noCard = el('div', 'outcome-card no')
289
  noCard.append(el('div', 'outcome-name', 'NO'), noPriceEl, noDeltaEl, sparkNo)
290
 
 
303
  const aiHeader = el('div', 'flex-row gap-8 mb-4 flex-wrap')
304
  aiHeader.append(el('div', 'ai-label', 'Análisis IA · HuggingFace Qwen3-8B'), aiBadge, aiMeta)
305
 
306
+ // Texto IA construido con nodos DOM — ninguna cadena externa toca innerHTML
307
  const aiText = el('div', 'ai-text')
308
+ aiText.textContent = sig.summary || 'Aún no hay análisis de IA disponible.'
309
  if (sig.keyRisk) {
310
  const strong = document.createElement('strong')
311
  strong.textContent = 'Riesgo clave:'
 
341
 
342
  container.replaceChildren(header, outcomesRow, aiBox, simRow)
343
 
344
+ // Vincula botones del simulador
345
+ simYes.addEventListener('click', () => simulator.openPosition(m.id, 'YES', simAmount.value))
346
  simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
347
 
348
+ // Renderiza graficos
349
  charts.renderDetailChart('detail-chart', m.yesPrice)
350
  charts.renderSparkline('spark-yes', m.yesPrice, 'yes')
351
  charts.renderSparkline('spark-no', m.noPrice, 'no')
 
373
  tbody.replaceChildren()
374
 
375
  state.positions.forEach((p) => {
376
+ const m = state.markets.find((x) => x.id === p.marketId) || p.market || { question: p.marketId }
377
+ const pnlColor = (p.pnl || 0) >= 0 ? 'td-green' : 'td-red'
378
+ const sign = (p.pnl || 0) >= 0 ? '+' : ''
379
 
380
  const tr = document.createElement('tr')
381
 
382
  const tdQ = el('td')
383
+ tdQ.textContent = `${(m.question || p.marketId).substring(0, 40)}${(m.question || p.marketId).length > 40 ? '…' : ''}`
384
 
385
+ const tdOutcome = el('td', `td-mono ${p.outcome === 'YES' ? 'td-green' : 'td-red'}`)
386
+ tdOutcome.textContent = translateOutcome(p.outcome)
387
 
388
  const tdAmt = el('td', 'td-mono')
389
  tdAmt.textContent = `€${p.amountEur.toFixed(0)}`
 
395
  tdCurrent.textContent = formatPrice(p.currentPrice)
396
 
397
  const tdPnl = el('td', `td-mono ${pnlColor}`)
398
+ tdPnl.textContent = `${sign}€${(p.pnl || 0).toFixed(2)}`
399
 
400
  const tdKelly = el('td', 'td-mono td-blue')
401
  tdKelly.textContent = `${((p.kellyFraction || 0) * 100).toFixed(0)}%`
 
434
  tbody.replaceChildren()
435
 
436
  state.watchlist.forEach((w) => {
437
+ const m = state.markets.find((x) => x.id === w.marketId) || w.market || { question: w.marketId, category: '-', yesPrice: 0, noPrice: 0, volumeEur: 0 }
438
  const sig = state.signals.find((s) => s.marketId === w.marketId) || { signal: 'neutral' }
439
 
440
  const tr = document.createElement('tr')
441
 
442
  const tdQ = el('td')
443
+ tdQ.textContent = `${(m.question || w.marketId).substring(0, 40)}${(m.question || w.marketId).length > 40 ? '…' : ''}`
444
 
445
  const tdCat = el('td')
446
  tdCat.textContent = m.category || '-'
 
492
  tbody.replaceChildren()
493
 
494
  state.alerts.forEach((a) => {
495
+ const m = state.markets.find((x) => x.id === a.marketId) || a.market || { question: a.marketId }
496
 
497
  const tr = document.createElement('tr')
498
 
 
500
  tdDate.textContent = new Date(a.sentAt).toLocaleString('es-ES')
501
 
502
  const tdQ = el('td')
503
+ tdQ.textContent = `${(m.question || a.marketId).substring(0, 35)}${(m.question || a.marketId).length > 35 ? '…' : ''}`
504
 
505
  const typeBadge = el('span', 'signal-badge sig-neut')
506
  typeBadge.textContent = a.type
 
518
  /* ─── Carga de datos ─── */
519
  async function loadMarkets() {
520
  try {
521
+ state.markets = await api.getMarkets({ limit: 50 })
 
522
  } catch (e) {
523
+ console.error('Error cargando mercados:', e)
524
+ state.markets = []
525
  }
526
  }
527
 
528
  async function loadSignals() {
529
+ if (state.markets.length === 0) {
530
+ state.signals = []
531
+ return
532
+ }
533
  try {
534
+ const marketIds = state.markets.map((m) => m.id)
535
+ const results = await api.getSignalsBatch(marketIds)
536
+ state.signals = results.map((r) => ({ ...r, marketId: r.marketId }))
537
  } catch (e) {
538
+ console.error('Error cargando señales:', e)
539
+ state.signals = []
540
  }
541
  }
542
 
 
544
  try {
545
  state.positions = await api.getPositions()
546
  } catch (e) {
547
+ console.error('Error cargando posiciones:', e)
548
+ state.positions = []
549
  }
550
  }
551
 
 
553
  try {
554
  state.watchlist = await api.getWatchlist()
555
  } catch (e) {
556
+ console.error('Error cargando watchlist:', e)
557
  state.watchlist = []
558
  }
559
  }
 
562
  try {
563
  state.alerts = await api.getAlerts()
564
  } catch (e) {
565
+ console.error('Error cargando alertas:', e)
566
+ state.alerts = []
567
+ }
568
+ }
569
+
570
+ async function loadStats() {
571
+ try {
572
+ const stats = await api.getStats()
573
+ const statMarkets = document.getElementById('stat-markets')
574
+ if (statMarkets) statMarkets.textContent = (stats.marketsCount ?? 0).toLocaleString('es-ES')
575
+ const statVolume = document.getElementById('stat-volume')
576
+ if (statVolume) statVolume.textContent = formatCurrency(stats.volume24h || 0)
577
+ const statSignals = document.getElementById('stat-signals')
578
+ if (statSignals) statSignals.textContent = stats.signalsCount ?? 0
579
+ const statAlerts = document.getElementById('stat-alerts')
580
+ if (statAlerts) statAlerts.textContent = stats.alertsToday ?? 0
581
+ } catch (e) {
582
+ console.error('Error cargando stats:', e)
583
  }
584
  }
585
 
 
598
  })
599
  })
600
 
601
+ // Auto-login con credenciales demo antes de cargar datos protegidos
602
+ await ensureAuth()
603
+
604
  await loadMarkets()
605
  await loadSignals()
606
  await loadPositions()
607
  await loadWatchlist()
608
  await loadAlerts()
609
+ await loadStats()
610
 
611
  map.init('map-container', state.markets, state.signals, selectMarket)
612
  simulator.init(state)
 
622
  socket.on('market_update', (data) => {
623
  const m = state.markets.find((x) => x.id === data.marketId)
624
  if (m) {
625
+ // Solo copia campos numericos conocidos — nunca mezcle todo el payload
626
  if (typeof data.yesPrice === 'number') m.yesPrice = data.yesPrice
627
  if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
628
  if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
 
647
  state.alerts.unshift(data)
648
  if (state.view === 'alerts') renderAlerts()
649
  })
 
 
 
 
 
 
 
 
 
 
 
 
650
  }
frontend/src/capitals.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utilidades de geolocalización basadas en la aportación de Arnau.
3
+ * Importa datos de capitales desde JSON estático y construye índices
4
+ * para búsqueda O(1) por código ISO y búsqueda por nombre.
5
+ */
6
+
7
+ import capitals from './capitals.json'
8
+
9
+ // Índice para búsqueda O(1) por código ISO
10
+ const coordsByCode = new Map()
11
+ for (const c of capitals) {
12
+ if (c.pais_codigo) coordsByCode.set(c.pais_codigo.toUpperCase(), [c.latitud, c.longitud])
13
+ }
14
+
15
+ // Índice para búsqueda de respaldo por nombre (país y capital)
16
+ const nameIndex = new Map()
17
+ for (const c of capitals) {
18
+ nameIndex.set(c.pais.toLowerCase(), [c.latitud, c.longitud])
19
+ nameIndex.set(c.capital.toLowerCase(), [c.latitud, c.longitud])
20
+ }
21
+
22
+ /**
23
+ * Obtiene coordenadas por código de país ISO2.
24
+ * Recurre al dataset expandido de Arnau si no está en la lista hardcoded.
25
+ */
26
+ export function getCoordsByCode(countryCode) {
27
+ const code = countryCode?.toUpperCase()
28
+ if (!code) return null
29
+
30
+ // Resguardo heredado hardcoded (mantenido por compatibilidad)
31
+ const legacy = {
32
+ US: [37.09, -95.71], DE: [51.16, 10.45], GB: [55.37, -3.43],
33
+ BR: [-14.23, -51.92], CN: [35.86, 104.19], IN: [20.59, 78.96],
34
+ KR: [35.90, 127.76], SA: [23.88, 45.07], FR: [46.22, 2.21],
35
+ JP: [36.20, 138.25], AU: [-25.27, 133.77], CA: [56.13, -106.34],
36
+ RU: [61.52, 105.31], MX: [23.63, -102.55], ZA: [-30.55, 22.93],
37
+ }
38
+ return legacy[code] || coordsByCode.get(code) || null
39
+ }
40
+
41
+ /**
42
+ * Detecta país en el texto de la pregunta de un mercado.
43
+ * Devuelve coordenadas si se encuentra un país o capital conocido, null en caso contrario.
44
+ * Seguro: solo lee texto, sin inyección DOM.
45
+ */
46
+ export function detectCountryInText(text) {
47
+ if (!text) return null
48
+ const lower = text.toLowerCase()
49
+ for (const [key, coords] of nameIndex) {
50
+ if (lower.includes(key)) return coords
51
+ }
52
+ return null
53
+ }
frontend/src/capitals.json ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ { "pais_codigo": "GB", "pais": "United Kingdom", "capital": "London", "latitud": 51.5074456, "longitud": -0.1277653 },
3
+ { "pais_codigo": "NZ", "pais": "New Zealand", "capital": "Wellington", "latitud": -41.2887953, "longitud": 174.7772114 },
4
+ { "pais_codigo": "GR", "pais": "Greece", "capital": "Athens", "latitud": 37.9755648, "longitud": 23.7348324 },
5
+ { "pais_codigo": "SI", "pais": "Slovenia", "capital": "Ljubljana", "latitud": 46.0500268, "longitud": 14.5069289 },
6
+ { "pais_codigo": "DK", "pais": "Denmark", "capital": "Kobenhavn", "latitud": 55.6867243, "longitud": 12.5700724 },
7
+ { "pais_codigo": "IN", "pais": "India", "capital": "New Delhi", "latitud": 28.6138954, "longitud": 77.2090057 },
8
+ { "pais_codigo": "AT", "pais": "Austria", "capital": "Vienna", "latitud": 48.2083537, "longitud": 16.3725042 },
9
+ { "pais_codigo": "FR", "pais": "France", "capital": "Paris", "latitud": 48.8534951, "longitud": 2.3483915 },
10
+ { "pais_codigo": "CH", "pais": "Switzerland", "capital": "Bern", "latitud": 46.9484742, "longitud": 7.4521749 },
11
+ { "pais_codigo": "CA", "pais": "Canada", "capital": "Ottawa", "latitud": 45.4208777, "longitud": -75.6901106 },
12
+ { "pais_codigo": "IQ", "pais": "Irak", "capital": "Baghdad", "latitud": 33.3061701, "longitud": 44.3872213 },
13
+ { "pais_codigo": "ES", "pais": "Spain", "capital": "Madrid", "latitud": 40.416782, "longitud": -3.703507 },
14
+ { "pais_codigo": "AU", "pais": "Australia", "capital": "Canberra", "latitud": -35.2975906, "longitud": 149.1012676 },
15
+ { "pais_codigo": "AF", "pais": "Afganistan", "capital": "Kabul", "latitud": 34.5269503, "longitud": 69.1850584 },
16
+ { "pais_codigo": "CN", "pais": "China", "capital": "Pekin", "latitud": 39.9057136, "longitud": 116.3912972 },
17
+ { "pais_codigo": "ZA", "pais": "South Africa", "capital": "Praetorship", "latitud": -25.7459277, "longitud": 28.1879101 },
18
+ { "pais_codigo": "TR", "pais": "Turkey", "capital": "Ankara", "latitud": 39.9207759, "longitud": 32.8540497 },
19
+ { "pais_codigo": "SE", "pais": "Sweden", "capital": "Stockholm", "latitud": 59.3251172, "longitud": 18.0710935 },
20
+ { "pais_codigo": "IR", "pais": "Iran", "capital": "Tehran", "latitud": 35.6892523, "longitud": 51.3896004 },
21
+ { "pais_codigo": "UA", "pais": "Ukraine", "capital": "Київ", "latitud": 50.4500336, "longitud": 30.5241361 },
22
+ { "pais_codigo": "BY", "pais": "Belarus", "capital": "Мінск", "latitud": 53.9024716, "longitud": 27.5618225 },
23
+ { "pais_codigo": "CU", "pais": "Cuba", "capital": "Havana", "latitud": 23.135305, "longitud": -82.3589631 },
24
+ { "pais_codigo": "LV", "pais": "Letonia", "capital": "Rīga", "latitud": 56.9493977, "longitud": 24.1051846 },
25
+ { "pais_codigo": "CD", "pais": "Democratic Republic of the Congo", "capital": "Kinshasa", "latitud": -4.32171, "longitud": 15.3122511 },
26
+ { "pais_codigo": "LT", "pais": "Lituania", "capital": "Vilnius", "latitud": 54.6870458, "longitud": 25.2829111 },
27
+ { "pais_codigo": "KG", "pais": "Kirguistan", "capital": "Бишкек", "latitud": 42.8761424, "longitud": 74.6036724 },
28
+ { "pais_codigo": "UZ", "pais": "Uzbekistan", "capital": "Toshkent", "latitud": 41.3123363, "longitud": 69.2787079 },
29
+ { "pais_codigo": "TJ", "pais": "Tayikistan", "capital": "Душанбе", "latitud": 38.5767045, "longitud": 68.785433 },
30
+ { "pais_codigo": "MN", "pais": "Mongolia", "capital": "Ulaanbaatar", "latitud": 47.9184676, "longitud": 106.9177016 },
31
+ { "pais_codigo": "KZ", "pais": "Kazajistan", "capital": "Астана", "latitud": 51.1282205, "longitud": 71.4306682 },
32
+ { "pais_codigo": "NE", "pais": "Niger", "capital": "Niamey", "latitud": 13.524834, "longitud": 2.109823 },
33
+ { "pais_codigo": "AO", "pais": "Angola", "capital": "Luanda", "latitud": -8.8272699, "longitud": 13.2439512 },
34
+ { "pais_codigo": "ML", "pais": "Mali", "capital": "Bamako", "latitud": 12.649319, "longitud": -8.000337 },
35
+ { "pais_codigo": "TN", "pais": "Tunisia", "capital": "Tunisia", "latitud": 36.8002068, "longitud": 10.1857757 },
36
+ { "pais_codigo": "LR", "pais": "Liberia", "capital": "Monrovia", "latitud": 6.3203562, "longitud": -10.8060492 },
37
+ { "pais_codigo": "CF", "pais": "Republica Centroafricana", "capital": "Bangui", "latitud": 4.3635118, "longitud": 18.5835913 },
38
+ { "pais_codigo": "ZM", "pais": "Zambia", "capital": "Lusaka", "latitud": -15.4163395, "longitud": 28.2818414 },
39
+ { "pais_codigo": "BF", "pais": "Burkina Faso", "capital": "Ouagadougou", "latitud": 12.3681873, "longitud": -1.5270944 },
40
+ { "pais_codigo": "TD", "pais": "Chad", "capital": "N'Djaména", "latitud": 12.1191543, "longitud": 15.0502758 },
41
+ { "pais_codigo": "SN", "pais": "Senegal", "capital": "Dakar", "latitud": 14.693425, "longitud": -17.447938 },
42
+ { "pais_codigo": "SL", "pais": "Sierra Leona", "capital": "Freetown", "latitud": 8.479004, "longitud": -13.26795 },
43
+ { "pais_codigo": "GN", "pais": "Guinea", "capital": "Conakry", "latitud": 9.5170602, "longitud": -13.6998434 },
44
+ { "pais_codigo": "GA", "pais": "Gabon", "capital": "Libreville", "latitud": 0.4086518, "longitud": 9.4418849 },
45
+ { "pais_codigo": "ET", "pais": "Ethiopia", "capital": "Addis Ababa", "latitud": 9.0358287, "longitud": 38.7524127 },
46
+ { "pais_codigo": "GH", "pais": "Ghana", "capital": "Accra", "latitud": 5.5571096, "longitud": -0.2012376 },
47
+ { "pais_codigo": "MZ", "pais": "Mozambique", "capital": "Maputo", "latitud": -25.966213, "longitud": 32.56745 },
48
+ { "pais_codigo": "DJ", "pais": "Yibuti", "capital": "Djibouti", "latitud": 11.5936903, "longitud": 43.1472724 },
49
+ { "pais_codigo": "KE", "pais": "Kenia", "capital": "Nairobi", "latitud": -1.2890006, "longitud": 36.8172812 },
50
+ { "pais_codigo": "MG", "pais": "Madagascar", "capital": "Antananarivo", "latitud": -18.9100122, "longitud": 47.5255809 },
51
+ { "pais_codigo": "SD", "pais": "Sudan", "capital": "Khartoum", "latitud": 15.5635972, "longitud": 32.5349123 },
52
+ { "pais_codigo": "CV", "pais": "Cape Verde", "capital": "Praia", "latitud": 14.9162811, "longitud": -23.5095095 },
53
+ { "pais_codigo": "IL", "pais": "Israel", "capital": "Jerusalen", "latitud": 31.7788472, "longitud": 35.2257856 },
54
+ { "pais_codigo": "EE", "pais": "Estonia", "capital": "Tallinn", "latitud": 59.437242, "longitud": 24.7572693 },
55
+ { "pais_codigo": "ID", "pais": "Indonesia", "capital": "Jakarta", "latitud": -6.1754049, "longitud": 106.827168 },
56
+ { "pais_codigo": "LS", "pais": "Lesoto", "capital": "Maseru", "latitud": -29.310054, "longitud": 27.478222 },
57
+ { "pais_codigo": "BW", "pais": "Botswana", "capital": "Gaborone", "latitud": -24.6581357, "longitud": 25.9088474 },
58
+ { "pais_codigo": "NG", "pais": "Nigeria", "capital": "Abuja", "latitud": 9.0643305, "longitud": 7.4892974 },
59
+ { "pais_codigo": "KP", "pais": "North Korea", "capital": "Pyongyang", "latitud": 39.0167979, "longitud": 125.7473609 },
60
+ { "pais_codigo": "NA", "pais": "Namibia", "capital": "Windhoek", "latitud": -22.5776104, "longitud": 17.0772739 },
61
+ { "pais_codigo": "SO", "pais": "Somalia", "capital": "Mogadishu", "latitud": 2.0349312, "longitud": 45.3419183 },
62
+ { "pais_codigo": "BH", "pais": "Bahrain", "capital": "Manama", "latitud": 26.2235041, "longitud": 50.5822436 },
63
+ { "pais_codigo": "CL", "pais": "Chile", "capital": "Santiago", "latitud": -33.4376995, "longitud": -70.6510671 },
64
+ { "pais_codigo": "LU", "pais": "Luxembourg", "capital": "Luxembourg", "latitud": 49.6112768, "longitud": 6.129799 },
65
+ { "pais_codigo": "JM", "pais": "Jamaica", "capital": "Kingston", "latitud": 17.9712148, "longitud": -76.7928128 },
66
+ { "pais_codigo": "EC", "pais": "Ecuador", "capital": "Quito", "latitud": -0.2201641, "longitud": -78.5123274 },
67
+ { "pais_codigo": "AD", "pais": "Andorra", "capital": "Andorra la Vella", "latitud": 42.5069391, "longitud": 1.5212467 },
68
+ { "pais_codigo": "RW", "pais": "Ruanda", "capital": "Kigali", "latitud": -1.9534357, "longitud": 30.1140089 },
69
+ { "pais_codigo": "RS", "pais": "Serbia", "capital": "Belgrade", "latitud": 44.8178131, "longitud": 20.4568974 },
70
+ { "pais_codigo": "MX", "pais": "Mexico", "capital": "Mexico City", "latitud": 19.4326296, "longitud": -99.1331785 },
71
+ { "pais_codigo": "NP", "pais": "Nepal", "capital": "Kathmandu", "latitud": 27.708317, "longitud": 85.3205817 },
72
+ { "pais_codigo": "IT", "pais": "Italy", "capital": "Rome", "latitud": 41.8933203, "longitud": 12.4829321 },
73
+ { "pais_codigo": "VN", "pais": "Vietnam", "capital": "Hanoi", "latitud": 21.0283334, "longitud": 105.854041 },
74
+ { "pais_codigo": "AR", "pais": "Argentina", "capital": "Buenos Aires", "latitud": -34.6095579, "longitud": -58.3887904 },
75
+ { "pais_codigo": "HU", "pais": "Hungary", "capital": "Budapest", "latitud": 47.4978789, "longitud": 19.0402383 },
76
+ { "pais_codigo": "TZ", "pais": "Tanzania", "capital": "Dodoma", "latitud": -6.1791181, "longitud": 35.7468174 },
77
+ { "pais_codigo": "RO", "pais": "Rumania", "capital": "Bucharest", "latitud": 44.4361414, "longitud": 26.102684 },
78
+ { "pais_codigo": "SS", "pais": "South Sudan", "capital": "Juba", "latitud": 4.8459246, "longitud": 31.5959173 },
79
+ { "pais_codigo": "VU", "pais": "Vanuatu", "capital": "Port Vila", "latitud": -17.7414972, "longitud": 168.3150163 },
80
+ { "pais_codigo": "BS", "pais": "Bahamas", "capital": "Nassau", "latitud": 25.0782266, "longitud": -77.3383438 },
81
+ { "pais_codigo": "LA", "pais": "Laos", "capital": "Vientian", "latitud": 17.9640988, "longitud": 102.6133707 },
82
+ { "pais_codigo": "NO", "pais": "Norway", "capital": "Oslo", "latitud": 59.8937521, "longitud": 10.6203118 },
83
+ { "pais_codigo": "ME", "pais": "Montenegro", "capital": "Podgorica", "latitud": 42.4415238, "longitud": 19.2621081 },
84
+ { "pais_codigo": "MD", "pais": "Moldavia", "capital": "Chişinău", "latitud": 47.0245117, "longitud": 28.8322923 },
85
+ { "pais_codigo": "AM", "pais": "Armenia", "capital": "Երևան", "latitud": 40.1777112, "longitud": 44.5126233 },
86
+ { "pais_codigo": "US", "pais": "United States", "capital": "Washington", "latitud": 38.8950982, "longitud": -77.0363849 },
87
+ { "pais_codigo": "MK", "pais": "North Macedonia", "capital": "Скопје", "latitud": 41.9962164, "longitud": 21.4318935 },
88
+ { "pais_codigo": "PH", "pais": "Philippines", "capital": "Manila", "latitud": 14.5904492, "longitud": 120.9803621 },
89
+ { "pais_codigo": "OM", "pais": "Oman", "capital": "Mascate", "latitud": 23.6123628, "longitud": 58.5938134 },
90
+ { "pais_codigo": "PG", "pais": "Papua New Guinea", "capital": "Port Moresby", "latitud": -9.4743301, "longitud": 147.1599504 },
91
+ { "pais_codigo": "DE", "pais": "Germany", "capital": "Berlin", "latitud": 52.5173885, "longitud": 13.3951309 },
92
+ { "pais_codigo": "MU", "pais": "Mauricio", "capital": "Port Louis", "latitud": -20.1624522, "longitud": 57.5028044 },
93
+ { "pais_codigo": "SZ", "pais": "Swaziland", "capital": "Mbabane", "latitud": -26.325745, "longitud": 31.144663 },
94
+ { "pais_codigo": "GD", "pais": "Grenada", "capital": "St. George's", "latitud": 12.0535331, "longitud": -61.751805 },
95
+ { "pais_codigo": "VC", "pais": "Saint Vincent and the Grenadines", "capital": "Kingstown", "latitud": 13.1561864, "longitud": -61.2279621 },
96
+ { "pais_codigo": "BB", "pais": "Barbados", "capital": "Bridgetown", "latitud": 13.0977832, "longitud": -59.6184184 },
97
+ { "pais_codigo": "MR", "pais": "Mauritius", "capital": "Nuakchot", "latitud": 18.0792379, "longitud": -15.9780071 },
98
+ { "pais_codigo": "GM", "pais": "Gambia", "capital": "Banjul", "latitud": 13.45535, "longitud": -16.575646 },
99
+ { "pais_codigo": "PY", "pais": "Paraguay", "capital": "Asuncion", "latitud": -25.2800459, "longitud": -57.6343814 },
100
+ { "pais_codigo": "GW", "pais": "Guinea Bissau", "capital": "Bissau", "latitud": 11.861324, "longitud": -15.583055 },
101
+ { "pais_codigo": "YE", "pais": "Yemen", "capital": "Fury", "latitud": 15.3538569, "longitud": 44.2058841 },
102
+ { "pais_codigo": "AE", "pais": "United Arab Emirates", "capital": "Abu Dhabi", "latitud": 24.4538352, "longitud": 54.3774014 },
103
+ { "pais_codigo": "TL", "pais": "Timor-Leste", "capital": "Dili", "latitud": -8.5536809, "longitud": 125.5784093 },
104
+ { "pais_codigo": "JP", "pais": "Japan", "capital": "Tokio", "latitud": 35.6768601, "longitud": 139.7638947 },
105
+ { "pais_codigo": "PT", "pais": "Portugal", "capital": "Lisbon", "latitud": 38.7077507, "longitud": -9.1365919 },
106
+ { "pais_codigo": "CK", "pais": "Islas Cook", "capital": "Avarua", "latitud": -21.2074736, "longitud": -159.7708145 },
107
+ { "pais_codigo": "NL", "pais": "Netherlands", "capital": "Amsterdam", "latitud": 52.3730796, "longitud": 4.8924534 },
108
+ { "pais_codigo": "EG", "pais": "Egypt", "capital": "Cairo", "latitud": 30.0443879, "longitud": 31.2357257 },
109
+ { "pais_codigo": "SM", "pais": "Saint Marino", "capital": "City of Saint Marino", "latitud": 43.9363996, "longitud": 12.4466991 },
110
+ { "pais_codigo": "SR", "pais": "Suriname", "capital": "Paramaribo", "latitud": 5.8247628, "longitud": -55.1703941 },
111
+ { "pais_codigo": "BZ", "pais": "Belize", "capital": "Belmopan", "latitud": 17.250199, "longitud": -88.770018 },
112
+ { "pais_codigo": "ZW", "pais": "Zimbawe", "capital": "Harare", "latitud": -17.831773, "longitud": 31.045686 },
113
+ { "pais_codigo": "HT", "pais": "Haiti", "capital": "Port-au-Prince", "latitud": 18.547327, "longitud": -72.3395928 },
114
+ { "pais_codigo": "LB", "pais": "Lebanon", "capital": "Beirut", "latitud": 33.8959203, "longitud": 35.47843 },
115
+ { "pais_codigo": "GY", "pais": "Guyana", "capital": "Georgetown", "latitud": 6.8032561, "longitud": -58.1455403 },
116
+ { "pais_codigo": "ST", "pais": "São Tomé and Principe", "capital": "São Tomé", "latitud": 0.3389242, "longitud": 6.7313031 },
117
+ { "pais_codigo": "UY", "pais": "Uruguay", "capital": "Montevideo", "latitud": -34.9058916, "longitud": -56.1913095 },
118
+ { "pais_codigo": "MA", "pais": "Morocco", "capital": "Rabat", "latitud": 34.0218454, "longitud": -6.8408929 },
119
+ { "pais_codigo": "DZ", "pais": "Algeria", "capital": "Algiers", "latitud": 36.7729333, "longitud": 3.0588445 },
120
+ { "pais_codigo": "BI", "pais": "Burundi", "capital": "Gitega", "latitud": -3.4284953, "longitud": 29.9249718 },
121
+ { "pais_codigo": "BO", "pais": "Bolivia", "capital": "Sucre", "latitud": -19.0477251, "longitud": -65.2594306 },
122
+ { "pais_codigo": "DO", "pais": "Dominican Republic", "capital": "Santo Domingo", "latitud": 18.4713858, "longitud": -69.8918436 },
123
+ { "pais_codigo": "SA", "pais": "Saudi Arabia", "capital": "Riyadh", "latitud": 24.638916, "longitud": 46.7160104 },
124
+ { "pais_codigo": "MM", "pais": "Myanmar", "capital": "Naypyidaw", "latitud": 19.7753289, "longitud": 96.1032552 },
125
+ { "pais_codigo": "PK", "pais": "Pakistan", "capital": "Islamabad", "latitud": 33.6938118, "longitud": 73.0651511 },
126
+ { "pais_codigo": "CI", "pais": "Ivory Coast", "capital": "Yamoussoukro", "latitud": 6.8200066, "longitud": -5.2776034 },
127
+ { "pais_codigo": "CM", "pais": "Cameroon", "capital": "Yaoundé", "latitud": 3.8689867, "longitud": 11.5213344 },
128
+ { "pais_codigo": "KM", "pais": "Comoras", "capital": "Moroni", "latitud": -11.7040306, "longitud": 43.251909 },
129
+ { "pais_codigo": "MH", "pais": "Marshall Islands", "capital": "Majuro", "latitud": 7.0909924, "longitud": 171.3816354 },
130
+ { "pais_codigo": "WS", "pais": "Samoa", "capital": "Apia", "latitud": -13.8345235, "longitud": -171.7630955 },
131
+ { "pais_codigo": "PL", "pais": "Poland", "capital": "Warsaw", "latitud": 52.2319581, "longitud": 21.0067249 },
132
+ { "pais_codigo": "CG", "pais": "Congo", "capital": "Brazzaville", "latitud": -4.2694407, "longitud": 15.2712256 },
133
+ { "pais_codigo": "NU", "pais": "Niue", "capital": "Alofi", "latitud": -19.0534159, "longitud": -169.919199 },
134
+ { "pais_codigo": "GT", "pais": "Guatemala", "capital": "Guatemala City", "latitud": 14.6416142, "longitud": -90.5132836 },
135
+ { "pais_codigo": "SK", "pais": "Slovakia", "capital": "Bratislava", "latitud": 48.1516988, "longitud": 17.1093063 },
136
+ { "pais_codigo": "SG", "pais": "Singapore", "capital": "Singapore", "latitud": 1.2899175, "longitud": 103.8519072 },
137
+ { "pais_codigo": "KW", "pais": "Kuwait", "capital": "Kuwait", "latitud": 29.3796532, "longitud": 47.9734174 },
138
+ { "pais_codigo": "KH", "pais": "Cambodia", "capital": "Nom Pen", "latitud": 11.568271, "longitud": 104.9224426 },
139
+ { "pais_codigo": "CO", "pais": "Colombia", "capital": "Bogotá", "latitud": 4.6533817, "longitud": -74.0836331 },
140
+ { "pais_codigo": "KI", "pais": "Kiribati", "capital": "South Tarawa", "latitud": 1.3490778, "longitud": 173.0386512 },
141
+ { "pais_codigo": "FM", "pais": "Micronesia", "capital": "Palikir", "latitud": 6.920744, "longitud": 158.1627143 },
142
+ { "pais_codigo": "UG", "pais": "Uganda", "capital": "Kampala", "latitud": 0.3177137, "longitud": 32.5813539 },
143
+ { "pais_codigo": "SB", "pais": "Solomon Islands", "capital": "Honiara", "latitud": -9.4310769, "longitud": 159.9552552 },
144
+ { "pais_codigo": "VE", "pais": "Venezuela", "capital": "Caracas", "latitud": 10.5060934, "longitud": -66.9146008 },
145
+ { "pais_codigo": "GL", "pais": "Greenland", "capital": "Nuuk", "latitud": 64.1767049, "longitud": -51.7361444 },
146
+ { "pais_codigo": "TO", "pais": "Tonga", "capital": "Nukuʻalofa", "latitud": -21.1343401, "longitud": -175.201808 },
147
+ { "pais_codigo": "SY", "pais": "Syria", "capital": "Damascus", "latitud": 33.5130695, "longitud": 36.3095814 },
148
+ { "pais_codigo": "TW", "pais": "Taiwan", "capital": "Taipei", "latitud": 25.0375198, "longitud": 121.5636796 },
149
+ { "pais_codigo": "PA", "pais": "Panama", "capital": "Panamá", "latitud": 8.9714493, "longitud": -79.5341802 },
150
+ { "pais_codigo": "FI", "pais": "Finland", "capital": "Helsinki", "latitud": 60.1666204, "longitud": 24.9435408 },
151
+ { "pais_codigo": "BT", "pais": "Bhutan", "capital": "Thimphu", "latitud": 27.4713546, "longitud": 89.6336729 },
152
+ { "pais_codigo": "MT", "pais": "Malta", "capital": "Valletta", "latitud": 35.8989979, "longitud": 14.5136607 },
153
+ { "pais_codigo": "HR", "pais": "Croatia", "capital": "Zagreb", "latitud": 45.8130967, "longitud": 15.9772795 },
154
+ { "pais_codigo": "CZ", "pais": "Czechia", "capital": "Prague", "latitud": 50.0874654, "longitud": 14.4212535 },
155
+ { "pais_codigo": "GE", "pais": "Georgia", "capital": "Tbilisi", "latitud": 41.6934591, "longitud": 44.8014495 },
156
+ { "pais_codigo": "TH", "pais": "Thailand", "capital": "Bangkok", "latitud": 13.7524938, "longitud": 100.4935089 },
157
+ { "pais_codigo": "BE", "pais": "Belgium", "capital": "Brussels", "latitud": 50.8467372, "longitud": 4.352493 },
158
+ { "pais_codigo": "JO", "pais": "Jordania", "capital": "Aman", "latitud": 31.9515694, "longitud": 35.9239625 },
159
+ { "pais_codigo": "BJ", "pais": "Benin", "capital": "Porto-Novo", "latitud": 6.4990718, "longitud": 2.6253361 },
160
+ { "pais_codigo": "QA", "pais": "Qatar", "capital": "Doha", "latitud": 25.2856329, "longitud": 51.5264162 },
161
+ { "pais_codigo": "RU", "pais": "Russia", "capital": "Moscow", "latitud": 55.7505412, "longitud": 37.6174782 },
162
+ { "pais_codigo": "BN", "pais": "Brunei", "capital": "Bandar Seri Begawan", "latitud": 4.8895453, "longitud": 114.9417574 },
163
+ { "pais_codigo": "LY", "pais": "Libya", "capital": "Tripoli", "latitud": 32.896672, "longitud": 13.1777923 },
164
+ { "pais_codigo": "BG", "pais": "Bulgaria", "capital": "Sofia", "latitud": 42.6977028, "longitud": 23.3217359 },
165
+ { "pais_codigo": "MC", "pais": "Monaco", "capital": "Monaco", "latitud": 43.7310164, "longitud": 7.4209592 },
166
+ { "pais_codigo": "AL", "pais": "Albania", "capital": "Tirane", "latitud": 41.3281482, "longitud": 19.8184435 },
167
+ { "pais_codigo": "MY", "pais": "Malaysia", "capital": "Kuala Lumpur", "latitud": 3.1516964, "longitud": 101.6942371 },
168
+ { "pais_codigo": "CY", "pais": "Cyprus", "capital": "Nicosia", "latitud": 35.1746503, "longitud": 33.3638783 },
169
+ { "pais_codigo": "KR", "pais": "South Korea", "capital": "Seoul", "latitud": 37.5666791, "longitud": 126.9782914 },
170
+ { "pais_codigo": "LI", "pais": "Liechtenstein", "capital": "Vaduz", "latitud": 47.1392862, "longitud": 9.5227962 },
171
+ { "pais_codigo": "BA", "pais": "Bosnia and Herzegovina", "capital": "Sarajevo", "latitud": 43.8570713, "longitud": 18.4126147 },
172
+ { "pais_codigo": "IS", "pais": "Iceland", "capital": "Reykjavík", "latitud": 64.145981, "longitud": -21.9422367 },
173
+ { "pais_codigo": "AZ", "pais": "Azerbaijan", "capital": "Baku", "latitud": 40.3755885, "longitud": 49.8328009 },
174
+ { "pais_codigo": "TG", "pais": "Togo", "capital": "Lomé", "latitud": 6.130419, "longitud": 1.215829 },
175
+ { "pais_codigo": "IM", "pais": "Isle of Man", "capital": "Douglas", "latitud": 54.149774, "longitud": -4.4779021 },
176
+ { "pais_codigo": "FJ", "pais": "Fiji", "capital": "Suva", "latitud": -18.1415884, "longitud": 178.4421662 },
177
+ { "pais_codigo": "TT", "pais": "Trinidad and Tobago", "capital": "Port of Spain", "latitud": 10.6572678, "longitud": -61.5180173 },
178
+ { "pais_codigo": "XK", "pais": "Kosovo", "capital": "Prishtinë", "latitud": 42.6638771, "longitud": 21.1640849 },
179
+ { "pais_codigo": "GQ", "pais": "Equatorial Guinea", "capital": "Malabo", "latitud": 1.5994535, "longitud": 10.8268276 },
180
+ { "pais_codigo": "TV", "pais": "Tuvalu", "capital": "Funafuti", "latitud": -8.5199633, "longitud": 179.1982548 },
181
+ { "pais_codigo": "MV", "pais": "Maldives", "capital": "Male", "latitud": 4.1779879, "longitud": 73.5107387 },
182
+ { "pais_codigo": "BD", "pais": "Bangladesh", "capital": "Dhaka", "latitud": 23.7643863, "longitud": 90.3890144 },
183
+ { "pais_codigo": "IE", "pais": "Ireland", "capital": "Dublin", "latitud": 53.3493795, "longitud": -6.2605593 },
184
+ { "pais_codigo": "PW", "pais": "Palau", "capital": "Ngerulmud", "latitud": 7.5006446, "longitud": 134.6242864 },
185
+ { "pais_codigo": "PE", "pais": "Peru", "capital": "Lima", "latitud": -12.0459808, "longitud": -77.0305912 },
186
+ { "pais_codigo": "LC", "pais": "Saint Lucia", "capital": "Castries", "latitud": 14.0095966, "longitud": -60.9902359 },
187
+ { "pais_codigo": "DM", "pais": "Dominica", "capital": "Roseau", "latitud": 15.2991923, "longitud": -61.3872868 },
188
+ { "pais_codigo": "ER", "pais": "Eritrea", "capital": "Asmara", "latitud": 15.3389667, "longitud": 38.9326763 },
189
+ { "pais_codigo": "MW", "pais": "Malawi", "capital": "Lilongwe", "latitud": -13.9875107, "longitud": 33.768144 },
190
+ { "pais_codigo": "NI", "pais": "Nicaragua", "capital": "Managua", "latitud": 12.1547116, "longitud": -86.273725 },
191
+ { "pais_codigo": "SV", "pais": "El Salvador", "capital": "San Salvador", "latitud": 13.697629, "longitud": -89.191156 },
192
+ { "pais_codigo": "HN", "pais": "Honduras", "capital": "Tegucigalpa", "latitud": 14.1058135, "longitud": -87.2047053 },
193
+ { "pais_codigo": "CR", "pais": "Costa Rica", "capital": "San Jose", "latitud": 9.9327707, "longitud": -84.0796144 },
194
+ { "pais_codigo": "BR", "pais": "Brazil", "capital": "Brasilia", "latitud": -15.721487, "longitud": -48.1021719 }
195
+ ]
frontend/src/map.js CHANGED
@@ -23,31 +23,22 @@
23
 
24
  import L from 'leaflet'
25
  import 'leaflet/dist/leaflet.css'
26
-
27
- // Coordenadas aproximadas por countryCode ISO2
28
- const COORDS = {
29
- US: [37.09, -95.71],
30
- DE: [51.16, 10.45],
31
- GB: [55.37, -3.43],
32
- BR: [-14.23, -51.92],
33
- CN: [35.86, 104.19],
34
- IN: [20.59, 78.96],
35
- KR: [35.90, 127.76],
36
- SA: [23.88, 45.07],
37
- FR: [46.22, 2.21],
38
- JP: [36.20, 138.25],
39
- AU: [-25.27, 133.77],
40
- CA: [56.13, -106.34],
41
- RU: [61.52, 105.31],
42
- MX: [23.63, -102.55],
43
- ZA: [-30.55, 22.93],
44
- }
45
 
46
  let mapInstance = null
47
- let bubbles = {} // marketId -> circle marker
 
 
 
 
 
 
 
 
 
48
 
49
- function getCoords(countryCode) {
50
- return COORDS[countryCode?.toUpperCase()] || [20, 0]
51
  }
52
 
53
  function getSignalColor(signal) {
@@ -77,7 +68,7 @@ export function init(containerId, markets, signals, onSelect) {
77
  worldCopyJump: true,
78
  }).setView([25, 10], 2)
79
 
80
- // Dark tile layer (CartoDB Dark Matter)
81
  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
82
  attribution: '&copy;OpenStreetMap &copy;CartoDB',
83
  subdomains: 'abcd',
@@ -87,7 +78,7 @@ export function init(containerId, markets, signals, onSelect) {
87
  markets.forEach((m) => {
88
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
89
  const color = getSignalColor(sig.signal)
90
- const coords = getCoords(m.countryCode)
91
  const radius = getRadius(m.volumeEur)
92
 
93
  const circle = L.circleMarker(coords, {
@@ -106,7 +97,7 @@ export function init(containerId, markets, signals, onSelect) {
106
  fillOpacity: 0.8,
107
  }).addTo(mapInstance)
108
 
109
- // Build label text safely via textContent before getting outerHTML
110
  const labelSpan = document.createElement('span')
111
  labelSpan.className = 'map-label-text'
112
  labelSpan.textContent = m.countryCode || 'GL'
@@ -121,7 +112,7 @@ export function init(containerId, markets, signals, onSelect) {
121
  interactive: false,
122
  }).addTo(mapInstance)
123
 
124
- // Build popup DOM — textContent prevents any HTML injection from market data
125
  const popup = document.createElement('div')
126
  popup.className = 'map-popup'
127
  const popupCat = document.createElement('div')
@@ -153,7 +144,7 @@ export function init(containerId, markets, signals, onSelect) {
153
  export function updateBubble(marketId, newPrice) {
154
  const b = bubbles[marketId]
155
  if (!b) return
156
- // Slightly adjust radius based on new activity (mock behavior)
157
  const newRadius = b.circle.getRadius() + (Math.random() > 0.5 ? 0.5 : -0.5)
158
  b.circle.setRadius(Math.max(5, Math.min(22, newRadius)))
159
  }
 
23
 
24
  import L from 'leaflet'
25
  import 'leaflet/dist/leaflet.css'
26
+ import { getCoordsByCode, detectCountryInText } from './capitals.js'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  let mapInstance = null
29
+ let bubbles = {} // marketId -> marcador de circulo
30
+
31
+ function getCoords(market) {
32
+ // 1. intentar por countryCode ISO2 del mercado
33
+ const byCode = getCoordsByCode(market.countryCode)
34
+ if (byCode) return byCode
35
+
36
+ // 2. fallback: detectar país en el texto de la pregunta
37
+ const byText = detectCountryInText(market.question)
38
+ if (byText) return byText
39
 
40
+ // 3. default centrado en África (coordenadas genéricas)
41
+ return [20, 0]
42
  }
43
 
44
  function getSignalColor(signal) {
 
68
  worldCopyJump: true,
69
  }).setView([25, 10], 2)
70
 
71
+ // Capa de tiles oscura (CartoDB Dark Matter)
72
  L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
73
  attribution: '&copy;OpenStreetMap &copy;CartoDB',
74
  subdomains: 'abcd',
 
78
  markets.forEach((m) => {
79
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
80
  const color = getSignalColor(sig.signal)
81
+ const coords = getCoords(m)
82
  const radius = getRadius(m.volumeEur)
83
 
84
  const circle = L.circleMarker(coords, {
 
97
  fillOpacity: 0.8,
98
  }).addTo(mapInstance)
99
 
100
+ // Construye texto de etiqueta de forma segura via textContent antes de obtener outerHTML
101
  const labelSpan = document.createElement('span')
102
  labelSpan.className = 'map-label-text'
103
  labelSpan.textContent = m.countryCode || 'GL'
 
112
  interactive: false,
113
  }).addTo(mapInstance)
114
 
115
+ // Construye DOM del popup — textContent previene cualquier inyeccion HTML desde datos del mercado
116
  const popup = document.createElement('div')
117
  popup.className = 'map-popup'
118
  const popupCat = document.createElement('div')
 
144
  export function updateBubble(marketId, newPrice) {
145
  const b = bubbles[marketId]
146
  if (!b) return
147
+ // Ajusta ligeramente el radio basado en nueva actividad (comportamiento simulado)
148
  const newRadius = b.circle.getRadius() + (Math.random() > 0.5 ? 0.5 : -0.5)
149
  b.circle.setRadius(Math.max(5, Math.min(22, newRadius)))
150
  }
frontend/src/simulator.js CHANGED
@@ -3,14 +3,14 @@
3
  *
4
  * Responsabilidades:
5
  * - init(state) → recibe referencia al estado global de app.js.
6
- * - openPosition(...) → crea posicion via API; si falla, crea localmente.
7
- * - closePosition(id) → cierra posicion via API; si falla, elimina localmente.
8
  *
9
  * Flujo openPosition:
10
  * 1. Valida cantidad > 0.
11
  * 2. Obtiene precio de entrada del mercado actual.
12
  * 3. POST /api/v1/positions → si 2xx, actualiza estado.
13
- * 4. Si API no disponible → crea posicion local con ID fake (Date.now).
14
  *
15
  * Eventos:
16
  * - Emite CustomEvent 'positions:changed' para que app.js re-renderice.
@@ -48,32 +48,20 @@ export async function openPosition(marketId, outcome, amount) {
48
  try {
49
  const created = await api.createPosition(data)
50
  appState.positions.push(created)
 
51
  } catch (e) {
52
- // Fallback: create locally if API unavailable
53
- const fakeId = Date.now()
54
- appState.positions.push({
55
- id: fakeId,
56
- marketId,
57
- outcome,
58
- amountEur: amt,
59
- entryPrice,
60
- currentPrice: entryPrice,
61
- pnl: 0,
62
- kellyFraction: 0.2,
63
- openedAt: new Date().toISOString(),
64
- })
65
  }
66
-
67
- // Trigger re-render via app.js if needed
68
- document.dispatchEvent(new CustomEvent('positions:changed'))
69
  }
70
 
71
  export async function closePosition(positionId) {
72
  try {
73
  await api.closePosition(positionId)
 
 
74
  } catch (e) {
75
- console.warn('API closePosition failed, removing locally')
 
76
  }
77
- appState.positions = appState.positions.filter((p) => p.id !== positionId)
78
- document.dispatchEvent(new CustomEvent('positions:changed'))
79
  }
 
3
  *
4
  * Responsabilidades:
5
  * - init(state) → recibe referencia al estado global de app.js.
6
+ * - openPosition(...) → crea posicion via API POST /positions.
7
+ * - closePosition(id) → cierra posicion via API DELETE /positions/:id.
8
  *
9
  * Flujo openPosition:
10
  * 1. Valida cantidad > 0.
11
  * 2. Obtiene precio de entrada del mercado actual.
12
  * 3. POST /api/v1/positions → si 2xx, actualiza estado.
13
+ * 4. Si API falla → muestra error al usuario.
14
  *
15
  * Eventos:
16
  * - Emite CustomEvent 'positions:changed' para que app.js re-renderice.
 
48
  try {
49
  const created = await api.createPosition(data)
50
  appState.positions.push(created)
51
+ document.dispatchEvent(new CustomEvent('positions:changed'))
52
  } catch (e) {
53
+ console.error('Error abriendo posicion:', e)
54
+ alert('No se pudo abrir la posición. ¿Has iniciado sesión?')
 
 
 
 
 
 
 
 
 
 
 
55
  }
 
 
 
56
  }
57
 
58
  export async function closePosition(positionId) {
59
  try {
60
  await api.closePosition(positionId)
61
+ appState.positions = appState.positions.filter((p) => p.id !== positionId)
62
+ document.dispatchEvent(new CustomEvent('positions:changed'))
63
  } catch (e) {
64
+ console.error('Error cerrando posicion:', e)
65
+ alert('No se pudo cerrar la posición')
66
  }
 
 
67
  }