Jose Salazar commited on
Commit
c88afbc
Β·
unverified Β·
2 Parent(s): 8d47d81cf8c12a

Merge branch 'main' into feature/refactor-segurity

Browse files
backend/src/app.js CHANGED
@@ -34,6 +34,7 @@ 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
 
@@ -63,11 +64,12 @@ 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
  // Servir frontend estΓ‘tico en producciΓ³n (HuggingFace Spaces)
68
  if (config.NODE_ENV === 'production') {
69
  app.use(express.static('../frontend/dist'));
70
- app.get('*', (_req, res) => {
71
  res.sendFile('index.html', { root: '../frontend/dist' });
72
  });
73
  }
 
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 preferencesRoutes from './preferences/preferences.routes.js';
38
  import { notFound } from './middlewares/notFound.js';
39
  import { errorHandler } from './middlewares/errorHandler.js';
40
 
 
64
  app.use('/api/v1/watchlist', watchlistRoutes);
65
  app.use('/api/v1/alerts', alertsRoutes);
66
  app.use('/api/v1/stats', statsRoutes);
67
+ app.use('/api/v1/preferences', preferencesRoutes);
68
 
69
  // Servir frontend estΓ‘tico en producciΓ³n (HuggingFace Spaces)
70
  if (config.NODE_ENV === 'production') {
71
  app.use(express.static('../frontend/dist'));
72
+ app.get('/{*path}', (_req, res) => {
73
  res.sendFile('index.html', { root: '../frontend/dist' });
74
  });
75
  }
backend/src/config.js CHANGED
@@ -33,6 +33,7 @@ const schema = z.object({
33
  HF_SPACE_MODERNFINBERT_URL: z.string().optional(),
34
  HF_SPACE_QWEN_URL: z.string().optional(),
35
  OPENROUTER_API_KEY: z.string().optional(),
 
36
  FINNHUB_API_KEY: z.string().optional(),
37
  TELEGRAM_BOT_TOKEN: z.string().optional(),
38
  });
 
33
  HF_SPACE_MODERNFINBERT_URL: z.string().optional(),
34
  HF_SPACE_QWEN_URL: z.string().optional(),
35
  OPENROUTER_API_KEY: z.string().optional(),
36
+ DEEPSEEK_API_KEY: z.string().optional(),
37
  FINNHUB_API_KEY: z.string().optional(),
38
  TELEGRAM_BOT_TOKEN: z.string().optional(),
39
  });
backend/src/preferences/preferences.routes.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { getPrefs, setPrefs } from './preferences.store.js';
3
+ import { ok } from '../utils/apiResponse.js';
4
+
5
+ const router = Router();
6
+
7
+ router.get('/', (_req, res) => {
8
+ const prefs = getPrefs();
9
+ ok(res, { ...prefs, apiKey: prefs.apiKey ? '***' : '' });
10
+ });
11
+
12
+ router.put('/', (req, res) => {
13
+ const { mode, provider, apiKey, endpoint, model } = req.body;
14
+ const updated = setPrefs({ mode, provider, apiKey, endpoint, model });
15
+ ok(res, { ...updated, apiKey: updated.apiKey ? '***' : '' });
16
+ });
17
+
18
+ export default router;
backend/src/preferences/preferences.store.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const defaults = {
2
+ mode: 'auto', // 'auto' | 'external' | 'local' | 'custom'
3
+ provider: 'deepseek', // for external: 'deepseek' | 'openrouter' | 'huggingface'
4
+ apiKey: '',
5
+ endpoint: 'http://localhost:11434',
6
+ model: 'qwen3:8b',
7
+ };
8
+
9
+ let _prefs = { ...defaults };
10
+
11
+ export function getPrefs() {
12
+ return { ..._prefs };
13
+ }
14
+
15
+ export function setPrefs(updates) {
16
+ const allowed = ['mode', 'provider', 'apiKey', 'endpoint', 'model'];
17
+ for (const key of allowed) {
18
+ if (key in updates && updates[key] != null) {
19
+ _prefs[key] = String(updates[key]);
20
+ }
21
+ }
22
+ if (!['auto', 'external', 'local', 'custom'].includes(_prefs.mode)) _prefs.mode = 'auto';
23
+ if (!['deepseek', 'openrouter', 'huggingface'].includes(_prefs.provider)) _prefs.provider = 'deepseek';
24
+ return { ..._prefs };
25
+ }
backend/src/signals/aiPipeline.js CHANGED
@@ -23,12 +23,17 @@ import { config } from '../config.js';
23
  import { logger } from '../utils/logger.js';
24
  import { fetchFinancialNewsForMarket, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
25
  import { analyzeCryptoTarget } from '../utils/coingecko.client.js';
 
26
 
27
  const HF_API = 'https://api-inference.huggingface.co/models';
28
  const FINBERT_MODEL = 'ProsusAI/finbert';
29
  const QWEN_MODEL = 'Qwen/Qwen3-8B';
30
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
31
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat';
 
 
 
 
32
 
33
  // Clientes Gradio en cache para Spaces
34
  let modernFinBERTClient = null;
@@ -239,6 +244,63 @@ async function generateWithOpenRouter(market, headlines, cryptoContext = null) {
239
  return data;
240
  }
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  // ── rule-based fallback ───────────────────────────────────────────────────────
243
 
244
  function ruleBasedSignal(market) {
@@ -291,6 +353,65 @@ function normalizeSignal(result) {
291
  return result;
292
  }
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  // ── public API ────────────────────────────────────────────────────────────────
295
 
296
  /**
@@ -333,10 +454,44 @@ export async function run(market) {
333
  }
334
 
335
  // Paso 2: generacion de senal LLM con cadena de respaldo
 
336
  let result = null;
337
 
338
- // Intenta Space primero
339
- if (config.HF_SPACE_QWEN_URL) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  try {
341
  result = await generateWithQwenSpace(market, headlines, cryptoContext);
342
  result = normalizeSignal(result);
@@ -346,23 +501,23 @@ export async function run(market) {
346
  }
347
  }
348
 
349
- // Respaldo a API directa de HF
350
  if (!result && config.HF_TOKEN) {
351
  try {
352
  result = await generateWithQwen3Direct(market, headlines, cryptoContext);
353
  if (!validateSignal(result)) result = null;
354
  } catch (err) {
355
- logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 direct API failed, trying OpenRouter');
356
  }
357
  }
358
 
359
- // Respaldo a OpenRouter
360
- if (!result && config.OPENROUTER_API_KEY) {
361
  try {
362
- result = await generateWithOpenRouter(market, headlines, cryptoContext);
363
  if (!validateSignal(result)) result = null;
364
  } catch (err) {
365
- logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'OpenRouter failed, using rule-based');
366
  }
367
  }
368
 
 
23
  import { logger } from '../utils/logger.js';
24
  import { fetchFinancialNewsForMarket, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
25
  import { analyzeCryptoTarget } from '../utils/coingecko.client.js';
26
+ import { getPrefs } from '../preferences/preferences.store.js';
27
 
28
  const HF_API = 'https://api-inference.huggingface.co/models';
29
  const FINBERT_MODEL = 'ProsusAI/finbert';
30
  const QWEN_MODEL = 'Qwen/Qwen3-8B';
31
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
32
  const OPENROUTER_MODEL = 'deepseek/deepseek-chat';
33
+ const DEEPSEEK_API = 'https://api.deepseek.com/chat/completions';
34
+ const DEEPSEEK_MODEL = 'deepseek-chat';
35
+ const OLLAMA_API = 'http://localhost:11434/api/generate';
36
+ const OLLAMA_MODEL = 'qwen3.5:9b';
37
 
38
  // Clientes Gradio en cache para Spaces
39
  let modernFinBERTClient = null;
 
244
  return data;
245
  }
246
 
247
+ async function generateWithDeepSeek(market, headlines, cryptoContext = null) {
248
+ const content = await callChatCompletion(
249
+ DEEPSEEK_API,
250
+ DEEPSEEK_MODEL,
251
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
252
+ `Bearer ${config.DEEPSEEK_API_KEY}`,
253
+ );
254
+ const data = content ? extractJson(content) : null;
255
+ if (data) data.modelVersion = 'DeepSeek API';
256
+ return data;
257
+ }
258
+
259
+ function buildOllamaPrompt(market, headlines, cryptoContext = null) {
260
+ const newsSection = headlines.length
261
+ ? `News:\n${headlines.map((h) => `- ${h.headline}`).join('\n')}`
262
+ : 'No relevant news.';
263
+
264
+ const yes = market.yesPrice;
265
+ const priceContext = yes != null
266
+ ? `Implied YES probability: ${(yes * 100).toFixed(1)}%.`
267
+ : '';
268
+
269
+ const daysToClose = market.closesAt
270
+ ? Math.max(0, Math.ceil((new Date(market.closesAt) - Date.now()) / (1000 * 60 * 60 * 24)))
271
+ : null;
272
+ const timeSection = daysToClose != null ? `Days to resolution: ${daysToClose}.` : '';
273
+
274
+ return [
275
+ `Market: "${market.question}"`,
276
+ `Category: ${market.category ?? 'general'}`,
277
+ `YES price: ${yes ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
278
+ priceContext,
279
+ timeSection,
280
+ newsSection,
281
+ ``,
282
+ `Return ONLY JSON: {"signal":"bullish"|"bearish"|"neutral","confidence":0.0-1.0,"summary":"<2 sentences>","keyRisk":"<1 sentence>"}`,
283
+ ].join('\n');
284
+ }
285
+
286
+ async function generateWithOllama(market, headlines, cryptoContext = null) {
287
+ const userPrompt = buildOllamaPrompt(market, headlines, cryptoContext);
288
+ const body = {
289
+ model: OLLAMA_MODEL,
290
+ messages: [
291
+ { role: 'system', content: 'You are a concise prediction-market trader. Always respond with ONLY the requested JSON. DO NOT think. DO NOT use <think> tags. DO NOT explain your reasoning. Output raw JSON only.' },
292
+ { role: 'user', content: userPrompt },
293
+ ],
294
+ stream: false,
295
+ options: { temperature: 0.3, num_predict: 1200 },
296
+ };
297
+ const res = await httpPost('http://localhost:11434/api/chat', body, { timeout: 180_000, retries: 0 });
298
+ const content = res?.message?.content ?? null;
299
+ const data = content ? extractJson(content) : null;
300
+ if (data) data.modelVersion = 'Ollama Qwen3.5';
301
+ return data;
302
+ }
303
+
304
  // ── rule-based fallback ───────────────────────────────────────────────────────
305
 
306
  function ruleBasedSignal(market) {
 
353
  return result;
354
  }
355
 
356
+ // ── User-configured model ────────────────────────────────────────────────────
357
+
358
+ async function generateWithUserPrefs(market, headlines, cryptoContext, prefs) {
359
+ const { mode, provider, apiKey, endpoint, model } = prefs;
360
+
361
+ if (mode === 'external') {
362
+ const cfgMap = {
363
+ deepseek: { url: DEEPSEEK_API, mdl: DEEPSEEK_MODEL, label: 'DeepSeek (usuario)' },
364
+ openrouter: { url: OPENROUTER_API, mdl: OPENROUTER_MODEL, label: 'OpenRouter (usuario)' },
365
+ huggingface: { url: `${HF_API}/${QWEN_MODEL}/v1/chat/completions`, mdl: QWEN_MODEL, label: 'HuggingFace (usuario)' },
366
+ };
367
+ const cfg = cfgMap[provider];
368
+ if (!cfg || !apiKey) return null;
369
+ const content = await callChatCompletion(
370
+ cfg.url,
371
+ cfg.mdl,
372
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
373
+ `Bearer ${apiKey}`,
374
+ );
375
+ const data = content ? extractJson(content) : null;
376
+ if (data) data.modelVersion = cfg.label;
377
+ return data;
378
+ }
379
+
380
+ if (mode === 'local') {
381
+ const base = (endpoint || 'http://localhost:11434').replace(/\/$/, '');
382
+ const mdl = model || OLLAMA_MODEL;
383
+ const body = {
384
+ model: mdl,
385
+ messages: [
386
+ { role: 'system', content: 'You are a concise prediction-market trader. Always respond with ONLY the requested JSON. DO NOT use <think> tags. Output raw JSON only.' },
387
+ { role: 'user', content: buildOllamaPrompt(market, headlines, cryptoContext) },
388
+ ],
389
+ stream: false,
390
+ options: { temperature: 0.3, num_predict: 1200 },
391
+ };
392
+ const res = await httpPost(`${base}/api/chat`, body, { timeout: 180_000, retries: 0 });
393
+ const content = res?.message?.content ?? null;
394
+ const data = content ? extractJson(content) : null;
395
+ if (data) data.modelVersion = `Local ${mdl}`;
396
+ return data;
397
+ }
398
+
399
+ if (mode === 'custom') {
400
+ if (!endpoint) return null;
401
+ const content = await callChatCompletion(
402
+ endpoint,
403
+ model || 'default',
404
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
405
+ apiKey ? `Bearer ${apiKey}` : '',
406
+ );
407
+ const data = content ? extractJson(content) : null;
408
+ if (data) data.modelVersion = `Custom ${model || 'endpoint'}`;
409
+ return data;
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
  // ── public API ────────────────────────────────────────────────────────────────
416
 
417
  /**
 
454
  }
455
 
456
  // Paso 2: generacion de senal LLM con cadena de respaldo
457
+ // ORDEN: preferencias usuario β†’ DeepSeek β†’ OpenRouter β†’ HF Space β†’ HF directa β†’ Ollama β†’ regla
458
  let result = null;
459
 
460
+ // 0. Modelo configurado por el usuario (si no es 'auto')
461
+ const prefs = getPrefs();
462
+ if (prefs.mode !== 'auto') {
463
+ try {
464
+ result = await generateWithUserPrefs(market, headlines, cryptoContext, prefs);
465
+ result = normalizeSignal(result);
466
+ if (!validateSignal(result)) result = null;
467
+ } catch (err) {
468
+ logger.warn({ err: err.message, mode: prefs.mode, marketId: market.id }, 'User model failed, falling back to auto chain');
469
+ result = null;
470
+ }
471
+ }
472
+
473
+ // 1. DeepSeek API directa primero
474
+ if (!result && config.DEEPSEEK_API_KEY) {
475
+ try {
476
+ result = await generateWithDeepSeek(market, headlines, cryptoContext);
477
+ if (!validateSignal(result)) result = null;
478
+ } catch (err) {
479
+ logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'DeepSeek API failed, trying OpenRouter');
480
+ }
481
+ }
482
+
483
+ // 2. OpenRouter DeepSeek
484
+ if (!result && config.OPENROUTER_API_KEY) {
485
+ try {
486
+ result = await generateWithOpenRouter(market, headlines, cryptoContext);
487
+ if (!validateSignal(result)) result = null;
488
+ } catch (err) {
489
+ logger.warn({ err: err.message, status: err.status, marketId: market.id }, 'OpenRouter DeepSeek failed, trying HF Space');
490
+ }
491
+ }
492
+
493
+ // 3. Respaldo a HF Space
494
+ if (!result && config.HF_SPACE_QWEN_URL) {
495
  try {
496
  result = await generateWithQwenSpace(market, headlines, cryptoContext);
497
  result = normalizeSignal(result);
 
501
  }
502
  }
503
 
504
+ // 4. Respaldo a API directa de HF
505
  if (!result && config.HF_TOKEN) {
506
  try {
507
  result = await generateWithQwen3Direct(market, headlines, cryptoContext);
508
  if (!validateSignal(result)) result = null;
509
  } catch (err) {
510
+ logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 direct API failed, trying Ollama');
511
  }
512
  }
513
 
514
+ // 5. Ollama local (lento pero gratuito)
515
+ if (!result) {
516
  try {
517
+ result = await generateWithOllama(market, headlines, cryptoContext);
518
  if (!validateSignal(result)) result = null;
519
  } catch (err) {
520
+ logger.warn({ err: err.message, marketId: market.id }, 'Ollama failed, using rule-based');
521
  }
522
  }
523
 
frontend/index.html CHANGED
@@ -83,6 +83,10 @@
83
  <span class="nav-icon">⚑</span>
84
  <span class="nav-label">Alertas</span>
85
  </div>
 
 
 
 
86
  </nav>
87
  <div class="sidebar-footer">
88
  v0.1.0 Β· HF Spaces
@@ -154,12 +158,13 @@
154
  </div>
155
  <div class="topbar-filters desktop-only">
156
  <select class="filter-select" id="filter-trend" title="Tendencias">
157
- <option value="">πŸ”₯ Todos los mercados</option>
158
- <option value="hot">πŸ”₯ MΓ‘s activos</option>
159
- <option value="bullish-trend">πŸ“ˆ Tendencia alcista</option>
160
- <option value="bearish-trend">πŸ“‰ Tendencia bajista</option>
161
- <option value="volatile">⚑ MÑs volÑtiles</option>
162
- <option value="high-volume">πŸ“Š Alto volumen</option>
 
163
  </select>
164
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
165
  <option value="">Todas las categorΓ­as</option>
@@ -169,7 +174,9 @@
169
  <div class="topbar-actions">
170
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
171
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
172
- <button class="icon-btn" id="btn-notif" title="Notificaciones">β—‰</button>
 
 
173
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
174
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
175
  </div>
@@ -320,6 +327,82 @@
320
  </div>
321
  </section>
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  </main>
324
  </div>
325
 
@@ -387,6 +470,53 @@
387
  </div>
388
  </div>
389
 
390
- <script type="module" src="/src/main.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  </body>
392
  </html>
 
83
  <span class="nav-icon">⚑</span>
84
  <span class="nav-label">Alertas</span>
85
  </div>
86
+ <div class="nav-item" data-view="preferences">
87
+ <span class="nav-icon">βš™</span>
88
+ <span class="nav-label">Modelo IA</span>
89
+ </div>
90
  </nav>
91
  <div class="sidebar-footer">
92
  v0.1.0 Β· HF Spaces
 
158
  </div>
159
  <div class="topbar-filters desktop-only">
160
  <select class="filter-select" id="filter-trend" title="Tendencias">
161
+ <option value="">Todos los mercados</option>
162
+ <option value="hot">MΓ‘s activos</option>
163
+ <option value="bullish-trend">Tendencia alcista</option>
164
+ <option value="bearish-trend">Tendencia bajista</option>
165
+ <option value="volatile">MΓ‘s volΓ‘tiles</option>
166
+ <option value="high-volume">Alto volumen</option>
167
+ <option value="open-only">Mercados Abiertos</option>
168
  </select>
169
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
170
  <option value="">Todas las categorΓ­as</option>
 
174
  <div class="topbar-actions">
175
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
176
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
177
+ <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
178
+ <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
179
+ <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
180
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
181
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
182
  </div>
 
327
  </div>
328
  </section>
329
 
330
+ <!-- PREFERENCES VIEW (desktop only β€” mobile uses modal) -->
331
+ <section class="view" id="view-preferences">
332
+ <div class="panel full-height">
333
+ <div class="panel-header">
334
+ <div class="panel-title"><span>βš™</span> Modelo de IA</div>
335
+ </div>
336
+ <div class="panel-body">
337
+ <div class="prefs-view-body">
338
+
339
+ <div class="prefs-modes" id="view-prefs-modes">
340
+ <button class="prefs-mode-btn active" data-mode="auto">AutomΓ‘tico</button>
341
+ <button class="prefs-mode-btn" data-mode="external">Proveedor externo</button>
342
+ <button class="prefs-mode-btn" data-mode="local">Modelo local</button>
343
+ <button class="prefs-mode-btn" data-mode="custom">Personalizado</button>
344
+ </div>
345
+
346
+ <div class="prefs-section active" id="view-prefs-auto">
347
+ <p class="prefs-description">
348
+ El servidor usa su cadena de respaldo configurada por variables de entorno:<br>
349
+ <strong>DeepSeek β†’ OpenRouter β†’ HuggingFace β†’ Ollama local β†’ regla de precio.</strong>
350
+ </p>
351
+ </div>
352
+
353
+ <div class="prefs-section" id="view-prefs-external">
354
+ <div class="form-group">
355
+ <label for="view-prefs-provider">Proveedor</label>
356
+ <select class="filter-select prefs-select" id="view-prefs-provider">
357
+ <option value="deepseek">DeepSeek API</option>
358
+ <option value="openrouter">OpenRouter</option>
359
+ <option value="huggingface">HuggingFace Inference</option>
360
+ </select>
361
+ </div>
362
+ <div class="form-group">
363
+ <label for="view-prefs-api-key">Clave API</label>
364
+ <input type="password" id="view-prefs-api-key" placeholder="sk-..." autocomplete="off" />
365
+ </div>
366
+ </div>
367
+
368
+ <div class="prefs-section" id="view-prefs-local">
369
+ <p class="prefs-description">Compatible con Ollama. Usa <code>/api/chat</code> en la URL base.</p>
370
+ <div class="form-group">
371
+ <label for="view-prefs-local-url">URL del servidor</label>
372
+ <input type="text" id="view-prefs-local-url" placeholder="http://localhost:11434" />
373
+ </div>
374
+ <div class="form-group">
375
+ <label for="view-prefs-local-model">Nombre del modelo</label>
376
+ <input type="text" id="view-prefs-local-model" placeholder="qwen3:8b" />
377
+ </div>
378
+ </div>
379
+
380
+ <div class="prefs-section" id="view-prefs-custom">
381
+ <p class="prefs-description">Cualquier endpoint compatible con OpenAI Chat Completions.</p>
382
+ <div class="form-group">
383
+ <label for="view-prefs-custom-url">Endpoint URL</label>
384
+ <input type="text" id="view-prefs-custom-url" placeholder="https://api.example.com/v1/chat/completions" />
385
+ </div>
386
+ <div class="form-group">
387
+ <label for="view-prefs-custom-key">Clave API (opcional)</label>
388
+ <input type="password" id="view-prefs-custom-key" placeholder="sk-..." autocomplete="off" />
389
+ </div>
390
+ <div class="form-group">
391
+ <label for="view-prefs-custom-model">Nombre del modelo</label>
392
+ <input type="text" id="view-prefs-custom-model" placeholder="gpt-4o-mini" />
393
+ </div>
394
+ </div>
395
+
396
+ <div class="form-status" id="view-prefs-status"></div>
397
+ <div class="form-actions">
398
+ <button type="button" class="modal-submit" id="btn-view-save-prefs">Guardar</button>
399
+ </div>
400
+
401
+ </div>
402
+ </div>
403
+ </div>
404
+ </section>
405
+
406
  </main>
407
  </div>
408
 
 
470
  </div>
471
  </div>
472
 
473
+ <!-- Auth Modal -->
474
+ <div class="modal-overlay hidden" id="auth-modal">
475
+ <div class="modal">
476
+ <div class="modal-header">
477
+ <div class="modal-tabs">
478
+ <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
479
+ <button class="modal-tab" data-tab="register">Registrarse</button>
480
+ </div>
481
+ <button class="modal-close" id="modal-close" title="Cerrar">βœ•</button>
482
+ </div>
483
+ <div class="modal-body">
484
+ <!-- Login Form -->
485
+ <form class="modal-form active" id="form-login">
486
+ <div class="form-group">
487
+ <label for="login-email">Correo electrΓ³nico</label>
488
+ <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
489
+ </div>
490
+ <div class="form-group">
491
+ <label for="login-password">ContraseΓ±a</label>
492
+ <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" required />
493
+ </div>
494
+ <div class="form-error" id="login-error"></div>
495
+ <button type="submit" class="modal-submit">Entrar</button>
496
+ </form>
497
+
498
+ <!-- Register Form -->
499
+ <form class="modal-form" id="form-register">
500
+ <div class="form-group">
501
+ <label for="register-email">Correo electrΓ³nico</label>
502
+ <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
503
+ </div>
504
+ <div class="form-group">
505
+ <label for="register-password">ContraseΓ±a</label>
506
+ <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" required minlength="8" />
507
+ </div>
508
+ <div class="form-group">
509
+ <label for="register-password-confirm">Confirmar contraseΓ±a</label>
510
+ <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" required />
511
+ </div>
512
+ <div class="form-error" id="register-error"></div>
513
+ <button type="submit" class="modal-submit">Crear cuenta</button>
514
+ </form>
515
+ </div>
516
+ </div>
517
+ </div>
518
+
519
+
520
+ <script type="module" src="/src/main.js"></script>
521
  </body>
522
  </html>
frontend/src/api.js CHANGED
@@ -188,3 +188,15 @@ export async function getAlerts() {
188
  export async function getStats() {
189
  return fetchJson(`${BASE}/stats`)
190
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  export async function getStats() {
189
  return fetchJson(`${BASE}/stats`)
190
  }
191
+
192
+ /* ─── Preferences ─── */
193
+ export async function getPreferences() {
194
+ return fetchJson(`${BASE}/preferences`)
195
+ }
196
+
197
+ export async function savePreferences(prefs) {
198
+ return fetchJson(`${BASE}/preferences`, {
199
+ method: 'PUT',
200
+ body: JSON.stringify(prefs),
201
+ })
202
+ }
frontend/src/app.js CHANGED
@@ -271,6 +271,13 @@ function filterByTrend(markets, trendType) {
271
  .sort((a, b) => b.volume - a.volume)
272
  .map((w) => w.market)
273
 
 
 
 
 
 
 
 
274
  default:
275
  return markets
276
  }
@@ -300,6 +307,83 @@ function switchAuthTab(tab) {
300
  if (registerError) registerError.textContent = ''
301
  }
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  /* ─── Telegram Modal ─── */
304
  function openTelegramModal() {
305
  const modal = document.getElementById('telegram-modal')
@@ -539,6 +623,7 @@ function switchView(viewName) {
539
  if (viewName === 'positions') renderPositions()
540
  if (viewName === 'watchlist') renderWatchlist()
541
  if (viewName === 'alerts') renderAlerts()
 
542
  }
543
 
544
  /* ─── Sidebar toggle ─── */
@@ -549,6 +634,7 @@ function toggleSidebar() {
549
 
550
  /* ─── Panel toggle ─── */
551
  function togglePanel(panelId) {
 
552
  const panel = document.getElementById(`panel-${panelId}`)
553
  if (!panel) return
554
  const isCollapsed = panel.classList.toggle('collapsed')
@@ -556,6 +642,71 @@ function togglePanel(panelId) {
556
  else state.collapsedPanels.delete(panelId)
557
  }
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  /* ─── Signal card factory ─── */
560
  function makeSignalCard(m) {
561
  const sig = state.signals.find((s) => s.marketId === m.id) || null
@@ -646,6 +797,55 @@ function makeSignalCard(m) {
646
  card.append(edgeRow)
647
  }
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  card.addEventListener('click', () => selectMarket(card.dataset.market))
650
  return card
651
  }
@@ -1264,9 +1464,18 @@ export async function init() {
1264
  })
1265
  })
1266
 
 
 
 
 
 
 
 
1267
  // Telegram modal events
1268
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1269
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
 
 
1270
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1271
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1272
  if (e.target.id === 'telegram-modal') closeTelegramModal()
 
271
  .sort((a, b) => b.volume - a.volume)
272
  .map((w) => w.market)
273
 
274
+ case 'open-only':
275
+ // Solo mercados activos
276
+ return withTrend
277
+ .filter((w) => w.market.status === 'active')
278
+ .sort((a, b) => b.volume - a.volume)
279
+ .map((w) => w.market)
280
+
281
  default:
282
  return markets
283
  }
 
307
  if (registerError) registerError.textContent = ''
308
  }
309
 
310
+ /* ─── Preferences ─── */
311
+ const PREFS_KEY = 'polysignal_prefs'
312
+
313
+ async function persistPrefs(payload, statusEl) {
314
+ try {
315
+ await api.savePreferences(payload)
316
+ localStorage.setItem(PREFS_KEY, JSON.stringify({
317
+ mode: payload.mode,
318
+ provider: payload.provider,
319
+ endpoint: payload.endpoint,
320
+ model: payload.model,
321
+ }))
322
+ statusEl.textContent = 'ConfiguraciΓ³n guardada. Aplicada en el prΓ³ximo ciclo de seΓ±ales.'
323
+ statusEl.className = 'form-status success'
324
+ return true
325
+ } catch {
326
+ statusEl.textContent = 'Error al guardar. Comprueba la conexiΓ³n con el servidor.'
327
+ statusEl.className = 'form-status error'
328
+ return false
329
+ }
330
+ }
331
+
332
+ function renderPreferencesView() {
333
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')
334
+ setViewPrefsMode(saved.mode || 'auto')
335
+ const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
336
+ set('view-prefs-provider', saved.provider || 'deepseek')
337
+ set('view-prefs-api-key', '')
338
+ set('view-prefs-local-url', saved.endpoint || 'http://localhost:11434')
339
+ set('view-prefs-local-model', saved.model || 'qwen3:8b')
340
+ set('view-prefs-custom-url', saved.endpoint || '')
341
+ set('view-prefs-custom-key', '')
342
+ set('view-prefs-custom-model', saved.model || '')
343
+ const statusEl = document.getElementById('view-prefs-status')
344
+ if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status' }
345
+ }
346
+
347
+ function setViewPrefsMode(mode) {
348
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
349
+ btn.classList.toggle('active', btn.dataset.mode === mode)
350
+ })
351
+ document.querySelectorAll('#view-preferences .prefs-section').forEach((sec) => {
352
+ sec.classList.toggle('active', sec.id === `view-prefs-${mode}`)
353
+ })
354
+ }
355
+
356
+ async function handleViewPrefsSave() {
357
+ const statusEl = document.getElementById('view-prefs-status')
358
+ const activeMode = document.querySelector('#view-prefs-modes .prefs-mode-btn.active')?.dataset.mode || 'auto'
359
+ const val = (id) => document.getElementById(id)?.value?.trim() || ''
360
+ const payload = { mode: activeMode }
361
+ if (activeMode === 'external') {
362
+ payload.provider = val('view-prefs-provider')
363
+ const key = val('view-prefs-api-key')
364
+ if (!key) {
365
+ statusEl.textContent = 'Introduce la clave API del proveedor seleccionado.'
366
+ statusEl.className = 'form-status error'
367
+ return
368
+ }
369
+ payload.apiKey = key
370
+ } else if (activeMode === 'local') {
371
+ payload.endpoint = val('view-prefs-local-url') || 'http://localhost:11434'
372
+ payload.model = val('view-prefs-local-model') || 'qwen3:8b'
373
+ } else if (activeMode === 'custom') {
374
+ const url = val('view-prefs-custom-url')
375
+ if (!url) {
376
+ statusEl.textContent = 'Introduce la URL del endpoint.'
377
+ statusEl.className = 'form-status error'
378
+ return
379
+ }
380
+ payload.endpoint = url
381
+ payload.apiKey = val('view-prefs-custom-key')
382
+ payload.model = val('view-prefs-custom-model')
383
+ }
384
+ await persistPrefs(payload, statusEl)
385
+ }
386
+
387
  /* ─── Telegram Modal ─── */
388
  function openTelegramModal() {
389
  const modal = document.getElementById('telegram-modal')
 
623
  if (viewName === 'positions') renderPositions()
624
  if (viewName === 'watchlist') renderWatchlist()
625
  if (viewName === 'alerts') renderAlerts()
626
+ if (viewName === 'preferences') renderPreferencesView()
627
  }
628
 
629
  /* ─── Sidebar toggle ─── */
 
634
 
635
  /* ─── Panel toggle ─── */
636
  function togglePanel(panelId) {
637
+ if (!window.matchMedia('(max-width: 640px)').matches) return
638
  const panel = document.getElementById(`panel-${panelId}`)
639
  if (!panel) return
640
  const isCollapsed = panel.classList.toggle('collapsed')
 
642
  else state.collapsedPanels.delete(panelId)
643
  }
644
 
645
+ /* ─── Watchlist / Alert helpers ─── */
646
+ function isInWatchlist(marketId) {
647
+ return state.watchlist.some((w) => w.marketId === marketId)
648
+ }
649
+
650
+ function hasAlert(marketId) {
651
+ return state.watchlist.some((w) => w.marketId === marketId && w.alertThreshold != null)
652
+ }
653
+
654
+ async function toggleWatchlistCard(marketId, wBtn, aBtn) {
655
+ if (isInWatchlist(marketId)) {
656
+ try {
657
+ await api.removeFromWatchlist(marketId)
658
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
659
+ wBtn.textContent = 'β˜† Seguimiento'
660
+ wBtn.classList.remove('active')
661
+ wBtn.title = 'AΓ±adir a seguimiento'
662
+ aBtn.textContent = '⚑ Alertas'
663
+ aBtn.classList.remove('active')
664
+ aBtn.title = 'Activar alerta de precio'
665
+ } catch (e) { console.warn('Error al quitar de watchlist:', e) }
666
+ } else {
667
+ try {
668
+ const entry = await api.addToWatchlist(marketId)
669
+ state.watchlist.push(entry ?? { marketId })
670
+ wBtn.textContent = 'β˜… Seguimiento'
671
+ wBtn.classList.add('active')
672
+ wBtn.title = 'Quitar de seguimiento'
673
+ } catch (e) { console.warn('Error al aΓ±adir a watchlist:', e) }
674
+ }
675
+ }
676
+
677
+ function toggleAlertCard(marketId, aBtn, thresholdRow) {
678
+ if (hasAlert(marketId)) {
679
+ api.removeFromWatchlist(marketId)
680
+ .then(() => api.addToWatchlist(marketId, null))
681
+ .then((entry) => {
682
+ const idx = state.watchlist.findIndex((w) => w.marketId === marketId)
683
+ if (idx >= 0) state.watchlist[idx] = { ...state.watchlist[idx], alertThreshold: null }
684
+ else state.watchlist.push(entry ?? { marketId })
685
+ aBtn.textContent = '⚑ Alertas'
686
+ aBtn.classList.remove('active')
687
+ aBtn.title = 'Activar alerta de precio'
688
+ })
689
+ .catch((e) => console.warn('Error al desactivar alerta:', e))
690
+ } else {
691
+ thresholdRow.classList.toggle('hidden')
692
+ }
693
+ }
694
+
695
+ async function setAlertThreshold(marketId, threshold, aBtn, thresholdRow) {
696
+ try {
697
+ if (isInWatchlist(marketId)) {
698
+ await api.removeFromWatchlist(marketId)
699
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
700
+ }
701
+ const entry = await api.addToWatchlist(marketId, threshold)
702
+ state.watchlist.push(entry ?? { marketId, alertThreshold: threshold })
703
+ aBtn.textContent = '⚑ Alerta activa'
704
+ aBtn.classList.add('active')
705
+ aBtn.title = 'Desactivar alerta'
706
+ thresholdRow.classList.add('hidden')
707
+ } catch (e) { console.warn('Error al configurar alerta:', e) }
708
+ }
709
+
710
  /* ─── Signal card factory ─── */
711
  function makeSignalCard(m) {
712
  const sig = state.signals.find((s) => s.marketId === m.id) || null
 
797
  card.append(edgeRow)
798
  }
799
 
800
+ // ── Action buttons (Seguimiento + Alertas) ──
801
+ const inWatchlist = isInWatchlist(m.id)
802
+ const alertActive = hasAlert(m.id)
803
+
804
+ const wBtn = el('button', `card-btn-watch${inWatchlist ? ' active' : ''}`)
805
+ wBtn.textContent = inWatchlist ? 'β˜… Seguimiento' : 'β˜† Seguimiento'
806
+ wBtn.title = inWatchlist ? 'Quitar de seguimiento' : 'AΓ±adir a seguimiento'
807
+
808
+ const aBtn = el('button', `card-btn-alert${alertActive ? ' active' : ''}`)
809
+ aBtn.textContent = alertActive ? '⚑ Alerta activa' : '⚑ Alertas'
810
+ aBtn.title = alertActive ? 'Desactivar alerta' : 'Activar alerta de precio'
811
+
812
+ // Inline threshold form
813
+ const thresholdRow = el('div', 'card-threshold-row hidden')
814
+ const thresholdInput = el('input', 'threshold-input')
815
+ thresholdInput.type = 'number'
816
+ thresholdInput.min = '1'
817
+ thresholdInput.max = '99'
818
+ thresholdInput.placeholder = 'Umbral Β’'
819
+ thresholdInput.addEventListener('click', (e) => e.stopPropagation())
820
+
821
+ const confirmThreshold = async () => {
822
+ const val = parseInt(thresholdInput.value, 10)
823
+ if (!val || val < 1 || val > 99) { thresholdInput.style.borderColor = 'var(--red)'; return }
824
+ await setAlertThreshold(m.id, val / 100, aBtn, thresholdRow)
825
+ }
826
+
827
+ thresholdInput.addEventListener('keydown', (e) => {
828
+ if (e.key === 'Enter') { e.stopPropagation(); confirmThreshold() }
829
+ if (e.key === 'Escape') { e.stopPropagation(); thresholdRow.classList.add('hidden') }
830
+ })
831
+
832
+ const confirmBtn = el('button', 'threshold-confirm', 'βœ“')
833
+ confirmBtn.title = 'Confirmar umbral'
834
+ confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); confirmThreshold() })
835
+
836
+ const cancelBtn = el('button', 'threshold-cancel', 'βœ•')
837
+ cancelBtn.title = 'Cancelar'
838
+ cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); thresholdRow.classList.add('hidden') })
839
+
840
+ thresholdRow.append(el('span', 'threshold-label', 'Umbral (Β’):'), thresholdInput, confirmBtn, cancelBtn)
841
+
842
+ wBtn.addEventListener('click', async (e) => { e.stopPropagation(); await toggleWatchlistCard(m.id, wBtn, aBtn) })
843
+ aBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleAlertCard(m.id, aBtn, thresholdRow) })
844
+
845
+ const actions = el('div', 'card-actions')
846
+ actions.append(wBtn, aBtn)
847
+ card.append(actions, thresholdRow)
848
+
849
  card.addEventListener('click', () => selectMarket(card.dataset.market))
850
  return card
851
  }
 
1464
  })
1465
  })
1466
 
1467
+ // Preferences view events (all screen sizes)
1468
+ document.getElementById('btn-prefs')?.addEventListener('click', () => switchView('preferences'))
1469
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
1470
+ btn.addEventListener('click', () => setViewPrefsMode(btn.dataset.mode))
1471
+ })
1472
+ document.getElementById('btn-view-save-prefs')?.addEventListener('click', handleViewPrefsSave)
1473
+
1474
  // Telegram modal events
1475
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1476
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
1477
+ document.getElementById('btn-watchlist-mobile')?.addEventListener('click', () => switchView('watchlist'))
1478
+ document.getElementById('btn-alerts-mobile')?.addEventListener('click', () => switchView('alerts'))
1479
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1480
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1481
  if (e.target.id === 'telegram-modal') closeTelegramModal()
frontend/src/map.js CHANGED
@@ -27,6 +27,7 @@ import { getCoordsByCode, detectCountryInText } from './capitals.js'
27
 
28
  let mapInstance = null
29
  let bubbles = {} // marketId -> marcador de circulo
 
30
 
31
  // Hubs globales para mercados sin pais. Cubre TODOS los continentes para
32
  // que las bubbles no se concentren en US/EU/Asia. Cada hub tiene tambien un
@@ -158,6 +159,26 @@ function getCoords(market) {
158
  return jitter(pickFinancialHub(market.id), market.id, 3)
159
  }
160
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  function getSignalColor(signal) {
162
  if (signal === 'bullish') return '#22d37a'
163
  if (signal === 'bearish') return '#f04040'
@@ -193,6 +214,8 @@ export function init(containerId, markets, signals, onSelect) {
193
  maxZoom: 19,
194
  }).addTo(mapInstance)
195
 
 
 
196
  markets.forEach((m) => {
197
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
198
  const color = getSignalColor(sig.signal)
@@ -256,7 +279,15 @@ export function init(containerId, markets, signals, onSelect) {
256
  onSelect(m.id)
257
  })
258
 
259
- bubbles[m.id] = { circle, inner, label, color }
 
 
 
 
 
 
 
 
260
  })
261
  }
262
 
@@ -287,8 +318,10 @@ export function updateMarkers(markets, signals) {
287
  mapInstance.removeLayer(b.circle)
288
  mapInstance.removeLayer(b.inner)
289
  mapInstance.removeLayer(b.label)
 
290
  })
291
  bubbles = {}
 
292
 
293
  // Re-renderizar solo los mercados filtrados
294
  markets.forEach((m) => {
@@ -352,6 +385,14 @@ export function updateMarkers(markets, signals) {
352
  if (window.__onSelectMarket) window.__onSelectMarket(m.id)
353
  })
354
 
355
- bubbles[m.id] = { circle, inner, label, color }
 
 
 
 
 
 
 
 
356
  })
357
  }
 
27
 
28
  let mapInstance = null
29
  let bubbles = {} // marketId -> marcador de circulo
30
+ let diagonalLines = [] // { line, latLng, radiusPx }
31
 
32
  // Hubs globales para mercados sin pais. Cubre TODOS los continentes para
33
  // que las bubbles no se concentren en US/EU/Asia. Cada hub tiene tambien un
 
159
  return jitter(pickFinancialHub(market.id), market.id, 3)
160
  }
161
 
162
+ function createDiagonalLine(latLng, radiusPx) {
163
+ if (!mapInstance) return null
164
+ const center = mapInstance.latLngToLayerPoint(L.latLng(latLng))
165
+ const offset = radiusPx * 0.9
166
+ const p1 = mapInstance.layerPointToLatLng(L.point(center.x - offset, center.y - offset))
167
+ const p2 = mapInstance.layerPointToLatLng(L.point(center.x + offset, center.y + offset))
168
+ const line = L.polyline([p1, p2], { color: '#ffffff', weight: 2.5, opacity: 0.9 }).addTo(mapInstance)
169
+ return { line, latLng, radiusPx }
170
+ }
171
+
172
+ function updateDiagonalLines() {
173
+ diagonalLines.forEach(({ line, latLng, radiusPx }) => {
174
+ const center = mapInstance.latLngToLayerPoint(L.latLng(latLng))
175
+ const offset = radiusPx * 0.9
176
+ const p1 = mapInstance.layerPointToLatLng(L.point(center.x - offset, center.y - offset))
177
+ const p2 = mapInstance.layerPointToLatLng(L.point(center.x + offset, center.y + offset))
178
+ line.setLatLngs([p1, p2])
179
+ })
180
+ }
181
+
182
  function getSignalColor(signal) {
183
  if (signal === 'bullish') return '#22d37a'
184
  if (signal === 'bearish') return '#f04040'
 
214
  maxZoom: 19,
215
  }).addTo(mapInstance)
216
 
217
+ mapInstance.on('zoomend', updateDiagonalLines)
218
+
219
  markets.forEach((m) => {
220
  const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
221
  const color = getSignalColor(sig.signal)
 
279
  onSelect(m.id)
280
  })
281
 
282
+ const bubble = { circle, inner, label, color }
283
+ if (m.status !== 'active') {
284
+ const d = createDiagonalLine(coords, radius)
285
+ if (d) {
286
+ diagonalLines.push(d)
287
+ bubble.diagonal = d
288
+ }
289
+ }
290
+ bubbles[m.id] = bubble
291
  })
292
  }
293
 
 
318
  mapInstance.removeLayer(b.circle)
319
  mapInstance.removeLayer(b.inner)
320
  mapInstance.removeLayer(b.label)
321
+ if (b.diagonal) mapInstance.removeLayer(b.diagonal.line)
322
  })
323
  bubbles = {}
324
+ diagonalLines = []
325
 
326
  // Re-renderizar solo los mercados filtrados
327
  markets.forEach((m) => {
 
385
  if (window.__onSelectMarket) window.__onSelectMarket(m.id)
386
  })
387
 
388
+ const bubble = { circle, inner, label, color }
389
+ if (m.status !== 'active') {
390
+ const d = createDiagonalLine(coords, radius)
391
+ if (d) {
392
+ diagonalLines.push(d)
393
+ bubble.diagonal = d
394
+ }
395
+ }
396
+ bubbles[m.id] = bubble
397
  })
398
  }
frontend/src/style.css CHANGED
@@ -212,6 +212,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
212
  width: 0;
213
  }
214
 
 
215
  .sidebar-footer {
216
  padding: 14px;
217
  border-top: 1px solid var(--border);
@@ -430,15 +431,9 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
430
  justify-content: space-between;
431
  padding: 14px 14px;
432
  border-bottom: 0.5px solid var(--border);
433
- cursor: pointer;
434
- user-select: none;
435
  transition: background 0.15s;
436
  }
437
 
438
- .panel-header:hover {
439
- background: rgba(255,255,255,0.02);
440
- }
441
-
442
  .panel-title {
443
  font-size: 0.875rem;
444
  color: var(--text3);
@@ -451,6 +446,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
451
  }
452
 
453
  .panel-toggle {
 
454
  font-size: 0.875rem;
455
  color: var(--text3);
456
  transition: transform 0.2s;
@@ -687,6 +683,105 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
687
  .edge-neg { color: var(--red, #f04040); background: var(--red3, rgba(240,64,64,0.08)); }
688
  .edge-zero { color: var(--text3, #6e7681); }
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  /* Sugerencia de tamano (Kelly) bajo el simulador */
691
  .kelly-note {
692
  flex: 1;
@@ -1808,6 +1903,19 @@ td {
1808
  }
1809
 
1810
  @media (max-width: 640px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
1811
  .layout {
1812
  grid-template-columns: 0 1fr;
1813
  grid-template-rows: auto 1fr;
@@ -2049,3 +2157,90 @@ td {
2049
  gap: 12px;
2050
  }
2051
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  width: 0;
213
  }
214
 
215
+
216
  .sidebar-footer {
217
  padding: 14px;
218
  border-top: 1px solid var(--border);
 
431
  justify-content: space-between;
432
  padding: 14px 14px;
433
  border-bottom: 0.5px solid var(--border);
 
 
434
  transition: background 0.15s;
435
  }
436
 
 
 
 
 
437
  .panel-title {
438
  font-size: 0.875rem;
439
  color: var(--text3);
 
446
  }
447
 
448
  .panel-toggle {
449
+ display: none;
450
  font-size: 0.875rem;
451
  color: var(--text3);
452
  transition: transform 0.2s;
 
683
  .edge-neg { color: var(--red, #f04040); background: var(--red3, rgba(240,64,64,0.08)); }
684
  .edge-zero { color: var(--text3, #6e7681); }
685
 
686
+ /* Botones de accion de tarjeta (Seguimiento + Alertas) */
687
+ .card-actions {
688
+ display: flex;
689
+ gap: 6px;
690
+ margin-top: 8px;
691
+ padding-top: 8px;
692
+ border-top: 0.5px solid var(--border, rgba(255,255,255,0.08));
693
+ }
694
+
695
+ .card-btn-watch,
696
+ .card-btn-alert {
697
+ flex: 1;
698
+ padding: 4px 8px;
699
+ font-size: 0.72rem;
700
+ font-family: var(--font-mono);
701
+ font-weight: 500;
702
+ letter-spacing: 0.03em;
703
+ border: 0.5px solid var(--border2);
704
+ border-radius: 4px;
705
+ background: var(--bg4);
706
+ color: var(--text2);
707
+ cursor: pointer;
708
+ transition: background 0.12s, color 0.12s, border-color 0.12s;
709
+ text-align: center;
710
+ white-space: nowrap;
711
+ }
712
+
713
+ .card-btn-watch:hover { border-color: var(--blue2); color: var(--blue); }
714
+ .card-btn-alert:hover { border-color: var(--amber2); color: var(--amber); }
715
+
716
+ .card-btn-watch.active {
717
+ background: var(--blue3);
718
+ color: var(--blue);
719
+ border-color: var(--blue2);
720
+ }
721
+
722
+ .card-btn-alert.active {
723
+ background: var(--amber3);
724
+ color: var(--amber);
725
+ border-color: var(--amber2);
726
+ }
727
+
728
+ /* Fila de umbral de alerta (inline en la tarjeta) */
729
+ .card-threshold-row {
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 6px;
733
+ margin-top: 6px;
734
+ padding: 6px 8px;
735
+ background: rgba(240,160,32,0.05);
736
+ border: 0.5px solid var(--amber2);
737
+ border-radius: 4px;
738
+ }
739
+ .card-threshold-row.hidden { display: none; }
740
+
741
+ .threshold-label {
742
+ font-size: 0.7rem;
743
+ color: var(--text2);
744
+ font-family: var(--font-mono);
745
+ flex-shrink: 0;
746
+ }
747
+
748
+ .threshold-input {
749
+ width: 72px;
750
+ padding: 3px 6px;
751
+ font-size: 0.78rem;
752
+ font-family: var(--font-mono);
753
+ background: var(--bg3);
754
+ border: 0.5px solid var(--border2);
755
+ border-radius: 3px;
756
+ color: var(--text);
757
+ outline: none;
758
+ }
759
+ .threshold-input:focus { border-color: var(--amber2); }
760
+
761
+ .threshold-confirm,
762
+ .threshold-cancel {
763
+ padding: 2px 8px;
764
+ font-size: 0.78rem;
765
+ border-radius: 3px;
766
+ cursor: pointer;
767
+ border: 0.5px solid;
768
+ font-family: var(--font-mono);
769
+ }
770
+
771
+ .threshold-confirm {
772
+ background: var(--green3);
773
+ color: var(--green);
774
+ border-color: var(--green2);
775
+ }
776
+ .threshold-confirm:hover { background: rgba(34,211,122,0.12); }
777
+
778
+ .threshold-cancel {
779
+ background: var(--bg4);
780
+ color: var(--text3);
781
+ border-color: var(--border2);
782
+ }
783
+ .threshold-cancel:hover { background: var(--bg3); }
784
+
785
  /* Sugerencia de tamano (Kelly) bajo el simulador */
786
  .kelly-note {
787
  flex: 1;
 
1903
  }
1904
 
1905
  @media (max-width: 640px) {
1906
+ .panel-header[data-panel] {
1907
+ cursor: pointer;
1908
+ user-select: none;
1909
+ }
1910
+
1911
+ .panel-header[data-panel]:hover {
1912
+ background: rgba(255,255,255,0.02);
1913
+ }
1914
+
1915
+ .panel-toggle {
1916
+ display: block;
1917
+ }
1918
+
1919
  .layout {
1920
  grid-template-columns: 0 1fr;
1921
  grid-template-rows: auto 1fr;
 
2157
  gap: 12px;
2158
  }
2159
  }
2160
+
2161
+ /* ─── Preferences View ─── */
2162
+
2163
+ .prefs-modes {
2164
+ display: flex;
2165
+ gap: 6px;
2166
+ margin-bottom: 20px;
2167
+ }
2168
+
2169
+ .prefs-mode-btn {
2170
+ flex: 1;
2171
+ background: var(--bg3);
2172
+ border: 0.5px solid var(--border2);
2173
+ color: var(--text2);
2174
+ font-size: 0.8125rem;
2175
+ font-family: var(--font-mono);
2176
+ text-transform: uppercase;
2177
+ letter-spacing: 0.05em;
2178
+ padding: 6px 14px;
2179
+ border-radius: var(--radius-sm);
2180
+ cursor: pointer;
2181
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
2182
+ }
2183
+
2184
+ .prefs-mode-btn:hover {
2185
+ color: var(--text);
2186
+ border-color: var(--text3);
2187
+ }
2188
+
2189
+ .prefs-mode-btn.active {
2190
+ background: var(--blue3);
2191
+ border-color: var(--blue2);
2192
+ color: var(--blue);
2193
+ }
2194
+
2195
+ .prefs-section {
2196
+ display: none;
2197
+ flex-direction: column;
2198
+ gap: 16px;
2199
+ margin-bottom: 8px;
2200
+ }
2201
+
2202
+ .prefs-section.active {
2203
+ display: flex;
2204
+ }
2205
+
2206
+ .prefs-description {
2207
+ font-size: 0.9375rem;
2208
+ color: var(--text2);
2209
+ line-height: 1.6;
2210
+ }
2211
+
2212
+ .prefs-description strong {
2213
+ color: var(--text);
2214
+ }
2215
+
2216
+ .prefs-description code {
2217
+ font-family: var(--font-mono);
2218
+ color: var(--blue);
2219
+ font-size: 0.875rem;
2220
+ }
2221
+
2222
+ .prefs-select {
2223
+ width: 100%;
2224
+ background: var(--bg3);
2225
+ border: 0.5px solid var(--border2);
2226
+ border-radius: var(--radius-sm);
2227
+ padding: 12px 14px;
2228
+ color: var(--text);
2229
+ font-size: 0.9375rem;
2230
+ font-family: var(--font-sans);
2231
+ outline: none;
2232
+ cursor: pointer;
2233
+ }
2234
+
2235
+ .prefs-select:focus {
2236
+ border-color: var(--blue2);
2237
+ }
2238
+
2239
+ .prefs-view-body {
2240
+ max-width: 520px;
2241
+ width: 100%;
2242
+ margin: 0 auto;
2243
+ display: flex;
2244
+ flex-direction: column;
2245
+ gap: 16px;
2246
+ }