Cifo_Administrador commited on
Commit
737a6e6
Β·
1 Parent(s): dbdc105

Cambios en UI, opcion de cambiar de proveedor de IA

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
@@ -29,6 +29,10 @@
29
  <span class="nav-icon">⚑</span>
30
  <span class="nav-label">Alertas</span>
31
  </div>
 
 
 
 
32
  </nav>
33
  <div class="sidebar-footer">
34
  v0.1.0 Β· HF Spaces
@@ -100,12 +104,13 @@
100
  </div>
101
  <div class="topbar-filters desktop-only">
102
  <select class="filter-select" id="filter-trend" title="Tendencias">
103
- <option value="">πŸ”₯ Todos los mercados</option>
104
- <option value="hot">πŸ”₯ MΓ‘s activos</option>
105
- <option value="bullish-trend">πŸ“ˆ Tendencia alcista</option>
106
- <option value="bearish-trend">πŸ“‰ Tendencia bajista</option>
107
- <option value="volatile">⚑ MÑs volÑtiles</option>
108
- <option value="high-volume">πŸ“Š Alto volumen</option>
 
109
  </select>
110
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
111
  <option value="">Todas las categorΓ­as</option>
@@ -115,7 +120,9 @@
115
  <div class="topbar-actions">
116
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
117
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
118
- <button class="icon-btn" id="btn-notif" title="Notificaciones">β—‰</button>
 
 
119
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
120
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
121
  </div>
@@ -266,6 +273,82 @@
266
  </div>
267
  </section>
268
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </main>
270
  </div>
271
 
@@ -379,6 +462,7 @@
379
  </div>
380
  </div>
381
 
382
- <script type="module" src="/src/main.js"></script>
 
383
  </body>
384
  </html>
 
29
  <span class="nav-icon">⚑</span>
30
  <span class="nav-label">Alertas</span>
31
  </div>
32
+ <div class="nav-item" data-view="preferences">
33
+ <span class="nav-icon">βš™</span>
34
+ <span class="nav-label">Modelo IA</span>
35
+ </div>
36
  </nav>
37
  <div class="sidebar-footer">
38
  v0.1.0 Β· HF Spaces
 
104
  </div>
105
  <div class="topbar-filters desktop-only">
106
  <select class="filter-select" id="filter-trend" title="Tendencias">
107
+ <option value="">Todos los mercados</option>
108
+ <option value="hot">MΓ‘s activos</option>
109
+ <option value="bullish-trend">Tendencia alcista</option>
110
+ <option value="bearish-trend">Tendencia bajista</option>
111
+ <option value="volatile">MΓ‘s volΓ‘tiles</option>
112
+ <option value="high-volume">Alto volumen</option>
113
+ <option value="open-only">Mercados Abiertos</option>
114
  </select>
115
  <select class="filter-select" id="filter-category" title="CategorΓ­a">
116
  <option value="">Todas las categorΓ­as</option>
 
120
  <div class="topbar-actions">
121
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
122
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
123
+ <button class="icon-btn mobile-only" id="btn-watchlist-mobile" title="Seguimiento">β˜†</button>
124
+ <button class="icon-btn mobile-only" id="btn-alerts-mobile" title="Alertas">⚑</button>
125
+ <button class="icon-btn mobile-only" id="btn-prefs" title="Modelo IA">βš™</button>
126
  <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
127
  <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
128
  </div>
 
273
  </div>
274
  </section>
275
 
276
+ <!-- PREFERENCES VIEW (desktop only β€” mobile uses modal) -->
277
+ <section class="view" id="view-preferences">
278
+ <div class="panel full-height">
279
+ <div class="panel-header">
280
+ <div class="panel-title"><span>βš™</span> Modelo de IA</div>
281
+ </div>
282
+ <div class="panel-body">
283
+ <div class="prefs-view-body">
284
+
285
+ <div class="prefs-modes" id="view-prefs-modes">
286
+ <button class="prefs-mode-btn active" data-mode="auto">AutomΓ‘tico</button>
287
+ <button class="prefs-mode-btn" data-mode="external">Proveedor externo</button>
288
+ <button class="prefs-mode-btn" data-mode="local">Modelo local</button>
289
+ <button class="prefs-mode-btn" data-mode="custom">Personalizado</button>
290
+ </div>
291
+
292
+ <div class="prefs-section active" id="view-prefs-auto">
293
+ <p class="prefs-description">
294
+ El servidor usa su cadena de respaldo configurada por variables de entorno:<br>
295
+ <strong>DeepSeek β†’ OpenRouter β†’ HuggingFace β†’ Ollama local β†’ regla de precio.</strong>
296
+ </p>
297
+ </div>
298
+
299
+ <div class="prefs-section" id="view-prefs-external">
300
+ <div class="form-group">
301
+ <label for="view-prefs-provider">Proveedor</label>
302
+ <select class="filter-select prefs-select" id="view-prefs-provider">
303
+ <option value="deepseek">DeepSeek API</option>
304
+ <option value="openrouter">OpenRouter</option>
305
+ <option value="huggingface">HuggingFace Inference</option>
306
+ </select>
307
+ </div>
308
+ <div class="form-group">
309
+ <label for="view-prefs-api-key">Clave API</label>
310
+ <input type="password" id="view-prefs-api-key" placeholder="sk-..." autocomplete="off" />
311
+ </div>
312
+ </div>
313
+
314
+ <div class="prefs-section" id="view-prefs-local">
315
+ <p class="prefs-description">Compatible con Ollama. Usa <code>/api/chat</code> en la URL base.</p>
316
+ <div class="form-group">
317
+ <label for="view-prefs-local-url">URL del servidor</label>
318
+ <input type="text" id="view-prefs-local-url" placeholder="http://localhost:11434" />
319
+ </div>
320
+ <div class="form-group">
321
+ <label for="view-prefs-local-model">Nombre del modelo</label>
322
+ <input type="text" id="view-prefs-local-model" placeholder="qwen3:8b" />
323
+ </div>
324
+ </div>
325
+
326
+ <div class="prefs-section" id="view-prefs-custom">
327
+ <p class="prefs-description">Cualquier endpoint compatible con OpenAI Chat Completions.</p>
328
+ <div class="form-group">
329
+ <label for="view-prefs-custom-url">Endpoint URL</label>
330
+ <input type="text" id="view-prefs-custom-url" placeholder="https://api.example.com/v1/chat/completions" />
331
+ </div>
332
+ <div class="form-group">
333
+ <label for="view-prefs-custom-key">Clave API (opcional)</label>
334
+ <input type="password" id="view-prefs-custom-key" placeholder="sk-..." autocomplete="off" />
335
+ </div>
336
+ <div class="form-group">
337
+ <label for="view-prefs-custom-model">Nombre del modelo</label>
338
+ <input type="text" id="view-prefs-custom-model" placeholder="gpt-4o-mini" />
339
+ </div>
340
+ </div>
341
+
342
+ <div class="form-status" id="view-prefs-status"></div>
343
+ <div class="form-actions">
344
+ <button type="button" class="modal-submit" id="btn-view-save-prefs">Guardar</button>
345
+ </div>
346
+
347
+ </div>
348
+ </div>
349
+ </div>
350
+ </section>
351
+
352
  </main>
353
  </div>
354
 
 
462
  </div>
463
  </div>
464
 
465
+
466
+ <script type="module" src="/src/main.js"></script>
467
  </body>
468
  </html>
frontend/src/api.js CHANGED
@@ -175,3 +175,15 @@ export async function getAlerts() {
175
  export async function getStats() {
176
  return fetchJson(`${BASE}/stats`)
177
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  export async function getStats() {
176
  return fetchJson(`${BASE}/stats`)
177
  }
178
+
179
+ /* ─── Preferences ─── */
180
+ export async function getPreferences() {
181
+ return fetchJson(`${BASE}/preferences`)
182
+ }
183
+
184
+ export async function savePreferences(prefs) {
185
+ return fetchJson(`${BASE}/preferences`, {
186
+ method: 'PUT',
187
+ body: JSON.stringify(prefs),
188
+ })
189
+ }
frontend/src/app.js CHANGED
@@ -241,6 +241,13 @@ function filterByTrend(markets, trendType) {
241
  .sort((a, b) => b.volume - a.volume)
242
  .map((w) => w.market)
243
 
 
 
 
 
 
 
 
244
  default:
245
  return markets
246
  }
@@ -268,6 +275,83 @@ function switchAuthTab(tab) {
268
  if (registerError) registerError.textContent = ''
269
  }
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  /* ─── Telegram Modal ─── */
272
  function openTelegramModal() {
273
  const modal = document.getElementById('telegram-modal')
@@ -445,6 +529,7 @@ function switchView(viewName) {
445
  if (viewName === 'positions') renderPositions()
446
  if (viewName === 'watchlist') renderWatchlist()
447
  if (viewName === 'alerts') renderAlerts()
 
448
  }
449
 
450
  /* ─── Sidebar toggle ─── */
@@ -455,6 +540,7 @@ function toggleSidebar() {
455
 
456
  /* ─── Panel toggle ─── */
457
  function togglePanel(panelId) {
 
458
  const panel = document.getElementById(`panel-${panelId}`)
459
  if (!panel) return
460
  const isCollapsed = panel.classList.toggle('collapsed')
@@ -462,6 +548,71 @@ function togglePanel(panelId) {
462
  else state.collapsedPanels.delete(panelId)
463
  }
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  /* ─── Signal card factory ─── */
466
  function makeSignalCard(m) {
467
  const sig = state.signals.find((s) => s.marketId === m.id) || null
@@ -552,6 +703,55 @@ function makeSignalCard(m) {
552
  card.append(edgeRow)
553
  }
554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  card.addEventListener('click', () => selectMarket(card.dataset.market))
556
  return card
557
  }
@@ -1170,9 +1370,18 @@ export async function init() {
1170
  })
1171
  })
1172
 
 
 
 
 
 
 
 
1173
  // Telegram modal events
1174
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1175
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
 
 
1176
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1177
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1178
  if (e.target.id === 'telegram-modal') closeTelegramModal()
 
241
  .sort((a, b) => b.volume - a.volume)
242
  .map((w) => w.market)
243
 
244
+ case 'open-only':
245
+ // Solo mercados activos
246
+ return withTrend
247
+ .filter((w) => w.market.status === 'active')
248
+ .sort((a, b) => b.volume - a.volume)
249
+ .map((w) => w.market)
250
+
251
  default:
252
  return markets
253
  }
 
275
  if (registerError) registerError.textContent = ''
276
  }
277
 
278
+ /* ─── Preferences ─── */
279
+ const PREFS_KEY = 'polysignal_prefs'
280
+
281
+ async function persistPrefs(payload, statusEl) {
282
+ try {
283
+ await api.savePreferences(payload)
284
+ localStorage.setItem(PREFS_KEY, JSON.stringify({
285
+ mode: payload.mode,
286
+ provider: payload.provider,
287
+ endpoint: payload.endpoint,
288
+ model: payload.model,
289
+ }))
290
+ statusEl.textContent = 'ConfiguraciΓ³n guardada. Aplicada en el prΓ³ximo ciclo de seΓ±ales.'
291
+ statusEl.className = 'form-status success'
292
+ return true
293
+ } catch {
294
+ statusEl.textContent = 'Error al guardar. Comprueba la conexiΓ³n con el servidor.'
295
+ statusEl.className = 'form-status error'
296
+ return false
297
+ }
298
+ }
299
+
300
+ function renderPreferencesView() {
301
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}')
302
+ setViewPrefsMode(saved.mode || 'auto')
303
+ const set = (id, val) => { const el = document.getElementById(id); if (el) el.value = val }
304
+ set('view-prefs-provider', saved.provider || 'deepseek')
305
+ set('view-prefs-api-key', '')
306
+ set('view-prefs-local-url', saved.endpoint || 'http://localhost:11434')
307
+ set('view-prefs-local-model', saved.model || 'qwen3:8b')
308
+ set('view-prefs-custom-url', saved.endpoint || '')
309
+ set('view-prefs-custom-key', '')
310
+ set('view-prefs-custom-model', saved.model || '')
311
+ const statusEl = document.getElementById('view-prefs-status')
312
+ if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status' }
313
+ }
314
+
315
+ function setViewPrefsMode(mode) {
316
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
317
+ btn.classList.toggle('active', btn.dataset.mode === mode)
318
+ })
319
+ document.querySelectorAll('#view-preferences .prefs-section').forEach((sec) => {
320
+ sec.classList.toggle('active', sec.id === `view-prefs-${mode}`)
321
+ })
322
+ }
323
+
324
+ async function handleViewPrefsSave() {
325
+ const statusEl = document.getElementById('view-prefs-status')
326
+ const activeMode = document.querySelector('#view-prefs-modes .prefs-mode-btn.active')?.dataset.mode || 'auto'
327
+ const val = (id) => document.getElementById(id)?.value?.trim() || ''
328
+ const payload = { mode: activeMode }
329
+ if (activeMode === 'external') {
330
+ payload.provider = val('view-prefs-provider')
331
+ const key = val('view-prefs-api-key')
332
+ if (!key) {
333
+ statusEl.textContent = 'Introduce la clave API del proveedor seleccionado.'
334
+ statusEl.className = 'form-status error'
335
+ return
336
+ }
337
+ payload.apiKey = key
338
+ } else if (activeMode === 'local') {
339
+ payload.endpoint = val('view-prefs-local-url') || 'http://localhost:11434'
340
+ payload.model = val('view-prefs-local-model') || 'qwen3:8b'
341
+ } else if (activeMode === 'custom') {
342
+ const url = val('view-prefs-custom-url')
343
+ if (!url) {
344
+ statusEl.textContent = 'Introduce la URL del endpoint.'
345
+ statusEl.className = 'form-status error'
346
+ return
347
+ }
348
+ payload.endpoint = url
349
+ payload.apiKey = val('view-prefs-custom-key')
350
+ payload.model = val('view-prefs-custom-model')
351
+ }
352
+ await persistPrefs(payload, statusEl)
353
+ }
354
+
355
  /* ─── Telegram Modal ─── */
356
  function openTelegramModal() {
357
  const modal = document.getElementById('telegram-modal')
 
529
  if (viewName === 'positions') renderPositions()
530
  if (viewName === 'watchlist') renderWatchlist()
531
  if (viewName === 'alerts') renderAlerts()
532
+ if (viewName === 'preferences') renderPreferencesView()
533
  }
534
 
535
  /* ─── Sidebar toggle ─── */
 
540
 
541
  /* ─── Panel toggle ─── */
542
  function togglePanel(panelId) {
543
+ if (!window.matchMedia('(max-width: 640px)').matches) return
544
  const panel = document.getElementById(`panel-${panelId}`)
545
  if (!panel) return
546
  const isCollapsed = panel.classList.toggle('collapsed')
 
548
  else state.collapsedPanels.delete(panelId)
549
  }
550
 
551
+ /* ─── Watchlist / Alert helpers ─── */
552
+ function isInWatchlist(marketId) {
553
+ return state.watchlist.some((w) => w.marketId === marketId)
554
+ }
555
+
556
+ function hasAlert(marketId) {
557
+ return state.watchlist.some((w) => w.marketId === marketId && w.alertThreshold != null)
558
+ }
559
+
560
+ async function toggleWatchlistCard(marketId, wBtn, aBtn) {
561
+ if (isInWatchlist(marketId)) {
562
+ try {
563
+ await api.removeFromWatchlist(marketId)
564
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
565
+ wBtn.textContent = 'β˜† Seguimiento'
566
+ wBtn.classList.remove('active')
567
+ wBtn.title = 'AΓ±adir a seguimiento'
568
+ aBtn.textContent = '⚑ Alertas'
569
+ aBtn.classList.remove('active')
570
+ aBtn.title = 'Activar alerta de precio'
571
+ } catch (e) { console.warn('Error al quitar de watchlist:', e) }
572
+ } else {
573
+ try {
574
+ const entry = await api.addToWatchlist(marketId)
575
+ state.watchlist.push(entry ?? { marketId })
576
+ wBtn.textContent = 'β˜… Seguimiento'
577
+ wBtn.classList.add('active')
578
+ wBtn.title = 'Quitar de seguimiento'
579
+ } catch (e) { console.warn('Error al aΓ±adir a watchlist:', e) }
580
+ }
581
+ }
582
+
583
+ function toggleAlertCard(marketId, aBtn, thresholdRow) {
584
+ if (hasAlert(marketId)) {
585
+ api.removeFromWatchlist(marketId)
586
+ .then(() => api.addToWatchlist(marketId, null))
587
+ .then((entry) => {
588
+ const idx = state.watchlist.findIndex((w) => w.marketId === marketId)
589
+ if (idx >= 0) state.watchlist[idx] = { ...state.watchlist[idx], alertThreshold: null }
590
+ else state.watchlist.push(entry ?? { marketId })
591
+ aBtn.textContent = '⚑ Alertas'
592
+ aBtn.classList.remove('active')
593
+ aBtn.title = 'Activar alerta de precio'
594
+ })
595
+ .catch((e) => console.warn('Error al desactivar alerta:', e))
596
+ } else {
597
+ thresholdRow.classList.toggle('hidden')
598
+ }
599
+ }
600
+
601
+ async function setAlertThreshold(marketId, threshold, aBtn, thresholdRow) {
602
+ try {
603
+ if (isInWatchlist(marketId)) {
604
+ await api.removeFromWatchlist(marketId)
605
+ state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
606
+ }
607
+ const entry = await api.addToWatchlist(marketId, threshold)
608
+ state.watchlist.push(entry ?? { marketId, alertThreshold: threshold })
609
+ aBtn.textContent = '⚑ Alerta activa'
610
+ aBtn.classList.add('active')
611
+ aBtn.title = 'Desactivar alerta'
612
+ thresholdRow.classList.add('hidden')
613
+ } catch (e) { console.warn('Error al configurar alerta:', e) }
614
+ }
615
+
616
  /* ─── Signal card factory ─── */
617
  function makeSignalCard(m) {
618
  const sig = state.signals.find((s) => s.marketId === m.id) || null
 
703
  card.append(edgeRow)
704
  }
705
 
706
+ // ── Action buttons (Seguimiento + Alertas) ──
707
+ const inWatchlist = isInWatchlist(m.id)
708
+ const alertActive = hasAlert(m.id)
709
+
710
+ const wBtn = el('button', `card-btn-watch${inWatchlist ? ' active' : ''}`)
711
+ wBtn.textContent = inWatchlist ? 'β˜… Seguimiento' : 'β˜† Seguimiento'
712
+ wBtn.title = inWatchlist ? 'Quitar de seguimiento' : 'AΓ±adir a seguimiento'
713
+
714
+ const aBtn = el('button', `card-btn-alert${alertActive ? ' active' : ''}`)
715
+ aBtn.textContent = alertActive ? '⚑ Alerta activa' : '⚑ Alertas'
716
+ aBtn.title = alertActive ? 'Desactivar alerta' : 'Activar alerta de precio'
717
+
718
+ // Inline threshold form
719
+ const thresholdRow = el('div', 'card-threshold-row hidden')
720
+ const thresholdInput = el('input', 'threshold-input')
721
+ thresholdInput.type = 'number'
722
+ thresholdInput.min = '1'
723
+ thresholdInput.max = '99'
724
+ thresholdInput.placeholder = 'Umbral Β’'
725
+ thresholdInput.addEventListener('click', (e) => e.stopPropagation())
726
+
727
+ const confirmThreshold = async () => {
728
+ const val = parseInt(thresholdInput.value, 10)
729
+ if (!val || val < 1 || val > 99) { thresholdInput.style.borderColor = 'var(--red)'; return }
730
+ await setAlertThreshold(m.id, val / 100, aBtn, thresholdRow)
731
+ }
732
+
733
+ thresholdInput.addEventListener('keydown', (e) => {
734
+ if (e.key === 'Enter') { e.stopPropagation(); confirmThreshold() }
735
+ if (e.key === 'Escape') { e.stopPropagation(); thresholdRow.classList.add('hidden') }
736
+ })
737
+
738
+ const confirmBtn = el('button', 'threshold-confirm', 'βœ“')
739
+ confirmBtn.title = 'Confirmar umbral'
740
+ confirmBtn.addEventListener('click', (e) => { e.stopPropagation(); confirmThreshold() })
741
+
742
+ const cancelBtn = el('button', 'threshold-cancel', 'βœ•')
743
+ cancelBtn.title = 'Cancelar'
744
+ cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); thresholdRow.classList.add('hidden') })
745
+
746
+ thresholdRow.append(el('span', 'threshold-label', 'Umbral (Β’):'), thresholdInput, confirmBtn, cancelBtn)
747
+
748
+ wBtn.addEventListener('click', async (e) => { e.stopPropagation(); await toggleWatchlistCard(m.id, wBtn, aBtn) })
749
+ aBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleAlertCard(m.id, aBtn, thresholdRow) })
750
+
751
+ const actions = el('div', 'card-actions')
752
+ actions.append(wBtn, aBtn)
753
+ card.append(actions, thresholdRow)
754
+
755
  card.addEventListener('click', () => selectMarket(card.dataset.market))
756
  return card
757
  }
 
1370
  })
1371
  })
1372
 
1373
+ // Preferences view events (all screen sizes)
1374
+ document.getElementById('btn-prefs')?.addEventListener('click', () => switchView('preferences'))
1375
+ document.querySelectorAll('#view-prefs-modes .prefs-mode-btn').forEach((btn) => {
1376
+ btn.addEventListener('click', () => setViewPrefsMode(btn.dataset.mode))
1377
+ })
1378
+ document.getElementById('btn-view-save-prefs')?.addEventListener('click', handleViewPrefsSave)
1379
+
1380
  // Telegram modal events
1381
  document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
1382
  document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
1383
+ document.getElementById('btn-watchlist-mobile')?.addEventListener('click', () => switchView('watchlist'))
1384
+ document.getElementById('btn-alerts-mobile')?.addEventListener('click', () => switchView('alerts'))
1385
  document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
1386
  document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
1387
  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;
@@ -1703,6 +1798,19 @@ td {
1703
  }
1704
 
1705
  @media (max-width: 640px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
1706
  .layout {
1707
  grid-template-columns: 0 1fr;
1708
  grid-template-rows: auto 1fr;
@@ -1944,3 +2052,90 @@ td {
1944
  gap: 12px;
1945
  }
1946
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;
 
1798
  }
1799
 
1800
  @media (max-width: 640px) {
1801
+ .panel-header[data-panel] {
1802
+ cursor: pointer;
1803
+ user-select: none;
1804
+ }
1805
+
1806
+ .panel-header[data-panel]:hover {
1807
+ background: rgba(255,255,255,0.02);
1808
+ }
1809
+
1810
+ .panel-toggle {
1811
+ display: block;
1812
+ }
1813
+
1814
  .layout {
1815
  grid-template-columns: 0 1fr;
1816
  grid-template-rows: auto 1fr;
 
2052
  gap: 12px;
2053
  }
2054
  }
2055
+
2056
+ /* ─── Preferences View ─── */
2057
+
2058
+ .prefs-modes {
2059
+ display: flex;
2060
+ gap: 6px;
2061
+ margin-bottom: 20px;
2062
+ }
2063
+
2064
+ .prefs-mode-btn {
2065
+ flex: 1;
2066
+ background: var(--bg3);
2067
+ border: 0.5px solid var(--border2);
2068
+ color: var(--text2);
2069
+ font-size: 0.8125rem;
2070
+ font-family: var(--font-mono);
2071
+ text-transform: uppercase;
2072
+ letter-spacing: 0.05em;
2073
+ padding: 6px 14px;
2074
+ border-radius: var(--radius-sm);
2075
+ cursor: pointer;
2076
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
2077
+ }
2078
+
2079
+ .prefs-mode-btn:hover {
2080
+ color: var(--text);
2081
+ border-color: var(--text3);
2082
+ }
2083
+
2084
+ .prefs-mode-btn.active {
2085
+ background: var(--blue3);
2086
+ border-color: var(--blue2);
2087
+ color: var(--blue);
2088
+ }
2089
+
2090
+ .prefs-section {
2091
+ display: none;
2092
+ flex-direction: column;
2093
+ gap: 16px;
2094
+ margin-bottom: 8px;
2095
+ }
2096
+
2097
+ .prefs-section.active {
2098
+ display: flex;
2099
+ }
2100
+
2101
+ .prefs-description {
2102
+ font-size: 0.9375rem;
2103
+ color: var(--text2);
2104
+ line-height: 1.6;
2105
+ }
2106
+
2107
+ .prefs-description strong {
2108
+ color: var(--text);
2109
+ }
2110
+
2111
+ .prefs-description code {
2112
+ font-family: var(--font-mono);
2113
+ color: var(--blue);
2114
+ font-size: 0.875rem;
2115
+ }
2116
+
2117
+ .prefs-select {
2118
+ width: 100%;
2119
+ background: var(--bg3);
2120
+ border: 0.5px solid var(--border2);
2121
+ border-radius: var(--radius-sm);
2122
+ padding: 12px 14px;
2123
+ color: var(--text);
2124
+ font-size: 0.9375rem;
2125
+ font-family: var(--font-sans);
2126
+ outline: none;
2127
+ cursor: pointer;
2128
+ }
2129
+
2130
+ .prefs-select:focus {
2131
+ border-color: var(--blue2);
2132
+ }
2133
+
2134
+ .prefs-view-body {
2135
+ max-width: 520px;
2136
+ width: 100%;
2137
+ margin: 0 auto;
2138
+ display: flex;
2139
+ flex-direction: column;
2140
+ gap: 16px;
2141
+ }