Jose Salazar commited on
Commit
8a4b117
Β·
1 Parent(s): 2ca3cf7

Correccion de bugs del pipeline de IA, incorporo openrouter con deepseek para fallback, rate limit de login diferenciado entre prod y dev, cambios varios en UI

Browse files
.claude/memory/feedback_no_chrome_devtools.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: No usar Chrome DevTools para verificar UI
3
+ description: El usuario prefiere verificar los cambios de UI Γ©l mismo, sin que Claude use Chrome DevTools
4
+ type: feedback
5
+ ---
6
+
7
+ No usar Chrome DevTools MCP para tomar screenshots o interactuar con el navegador despuΓ©s de hacer cambios de UI. El usuario prefiere revisar los cambios visualmente por su cuenta.
8
+
9
+ **Why:** El usuario lo indicΓ³ explΓ­citamente cuando se usaban las herramientas de Chrome para verificar cambios CSS/JS.
10
+
11
+ **How to apply:** DespuΓ©s de hacer cambios de CSS o JS en la UI, describir los cambios realizados y dejar que el usuario los verifique en el navegador.
CLAUDE.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project Overview
6
+
7
+ **PolySignal** β€” a real-time prediction market intelligence dashboard. It fetches market data from Polymarket, enriches it with financial news via Finnhub, runs AI sentiment analysis (ModernFinBERT + Qwen3-8B), and displays signals on an interactive world map. No real trading β€” pure analysis and virtual simulation. Built for CIFO Barcelona La Violeta Hackathon (May 2026). UI and docs are in **Spanish**, base currency is **Euro (€)**.
8
+
9
+ ## Common Commands
10
+
11
+ ```bash
12
+ # Development (backend + frontend simultaneously)
13
+ npm run dev:all
14
+
15
+ # Backend only (with --watch)
16
+ npm run dev
17
+
18
+ # Frontend only (Vite @ :5173)
19
+ npm run dev:frontend
20
+
21
+ # Production build (frontend)
22
+ npm run build:frontend
23
+
24
+ # Database
25
+ npm run db:migrate # Apply Prisma migrations
26
+ npm run db:generate # Regenerate Prisma client
27
+ npm run db:studio # Open Prisma Studio GUI
28
+
29
+ # Docker
30
+ docker-compose up --build # Full stack on port 7860
31
+ ```
32
+
33
+ Node.js **β‰₯24.0.0** is required (enforced in package.json engines).
34
+
35
+ First-time setup:
36
+ ```bash
37
+ npm install
38
+ cp .env.example .env # then fill in API keys
39
+ npm run db:migrate && npm run db:generate
40
+ ```
41
+
42
+ ## Architecture
43
+
44
+ Monorepo with two workspaces: `backend/` (Express 5 + Socket.io) and `frontend/` (Vanilla JS + Vite).
45
+
46
+ ### Backend (`backend/src/`)
47
+
48
+ Follows a strict **Controller β†’ Service β†’ Repository β†’ Client** layered pattern. Each domain lives in its own directory:
49
+
50
+ | Directory | Responsibility |
51
+ |-----------|---------------|
52
+ | `markets/` | Polymarket Gamma API client + sync job |
53
+ | `signals/` | AI pipeline (aiPipeline.js) + signals service |
54
+ | `finnhub/` | Finnhub news client + service |
55
+ | `positions/` | Virtual position simulator + Kelly Criterion sizing |
56
+ | `watchlist/` | User-saved markets |
57
+ | `alerts/` | Telegram Bot notification delivery |
58
+ | `auth/` | JWT + bcryptjs login |
59
+ | `socket/` | Socket.io broadcaster |
60
+ | `middlewares/` | Auth, validation, error handling, rate limiting |
61
+ | `utils/` | Pino logger, HTTP client, Prisma singleton |
62
+
63
+ Entry points: `src/index.js` (HTTP server + Socket.io setup) β†’ `src/app.js` (Express middleware + route mounting) β†’ `src/scheduler.js` (cron jobs).
64
+
65
+ Environment/config validated at startup via **Zod** in `src/config.js` β€” the app crashes fast if required env vars are missing.
66
+
67
+ ### Scheduler Jobs
68
+
69
+ | Job | Frequency | What it does |
70
+ |-----|-----------|-------------|
71
+ | `syncMarkets` | 30s | Fetches top 100 active Polymarket markets, broadcasts price changes via Socket.io |
72
+ | `generateSignals` | 5m | Runs full AI pipeline on top 20 active markets |
73
+ | `updatePositionsPnL` | 30s | Recalculates P&L for open virtual positions |
74
+ | `processAlerts` | 1m | Checks watchlist thresholds, fires Telegram alerts |
75
+
76
+ ### AI Signal Pipeline (`signals/aiPipeline.js`)
77
+
78
+ Three-phase flow with automatic fallbacks:
79
+
80
+ 1. **News filtering** β€” Finnhub headlines β†’ ModernFinBERT (HF Space) β†’ keep only scores β‰₯ 0.65, drop neutral
81
+ 2. **Signal generation** β€” market data + filtered news β†’ Qwen3-8B (HF Space) β†’ `{ signal, confidence, summary, keyRisk }`
82
+ 3. **Fallback chain**: HF Space β†’ HF direct inference API β†’ OpenRouter (deepseek-chat) β†’ rule-based (price-trend logic)
83
+
84
+ ### Frontend (`frontend/src/`)
85
+
86
+ Single-page app with no framework. Key modules:
87
+
88
+ | File | Role |
89
+ |------|------|
90
+ | `app.js` | SPA routing, DOM rendering, Socket.io listeners |
91
+ | `api.js` | REST client wrapper with JWT token management |
92
+ | `map.js` | Leaflet world map (bubble size = volume, color = signal) |
93
+ | `charts.js` | Chart.js sparklines + 7-day price history |
94
+ | `simulator.js` | Virtual buy/sell logic |
95
+ | `filters.js` | Market filtering by category, country, continent, trend |
96
+
97
+ Vite proxies `/api` and `/socket.io` to backend (`localhost:7860`) during development.
98
+
99
+ ### Database (SQLite via Prisma)
100
+
101
+ Schema at `backend/prisma/schema.prisma`. Key models:
102
+ - `Market` β€” Polymarket data (prices, volume, category, country code)
103
+ - `AISignal` β€” sentiment signals with confidence and risk summary
104
+ - `Position` β€” virtual trades with entry/exit prices and P&L
105
+ - `Watchlist` / `Alert` β€” user market tracking and Telegram history
106
+ - `User` β€” auth + optional Telegram chat ID
107
+
108
+ ### Real-time Communication
109
+
110
+ REST API at `/api/v1/*` + WebSocket events via Socket.io:
111
+ - `market_update` β€” price/volume changes
112
+ - `ai_signal` β€” new sentiment signals
113
+ - `price_alert` β€” watchlist threshold triggers
114
+
115
+ ## Deployment Target
116
+
117
+ The app is designed to run on **HuggingFace Spaces** (port 7860). The Dockerfile uses `node:22-slim`, installs backend deps, copies the frontend `dist/`, runs Prisma generate, and starts the server. The frontend is served as static files by Express in production.
118
+
119
+ ## Key Environment Variables
120
+
121
+ See `.env.example` for the full list. Critical ones:
122
+
123
+ ```
124
+ HF_SPACE_MODERNFINBERT_URL # HuggingFace Space for FinBERT
125
+ HF_SPACE_QWEN_URL # HuggingFace Space for Qwen3-8B
126
+ HF_TOKEN # HF inference API key (fallback)
127
+ OPENROUTER_API_KEY # LLM fallback if HF is down
128
+ FINNHUB_API_KEY # News source
129
+ TELEGRAM_BOT_TOKEN # Alert delivery
130
+ JWT_SECRET # Must be β‰₯32 characters
131
+ PORT=7860 # Required by HF Spaces
132
+ DATABASE_URL=file:./backend/prisma/polysignal.db
133
+ ```
README.md CHANGED
@@ -32,6 +32,66 @@ El backend es **totalmente funcional**:
32
 
33
  El frontend **consume datos reales del backend** y tiene **fallback a datos mock** cuando el backend no responde (modo demo sin configuracion).
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  ## Estructura
36
 
37
  ```
@@ -183,16 +243,22 @@ El backend sigue una arquitectura **Layered (Controller β†’ Service β†’ Reposito
183
  ### Pipeline de IA
184
 
185
  ```
186
- Noticias (Finnhub)
 
 
187
  ↓
188
  Filtrado (ModernFinBERT Space / API directa)
189
  ↓ (descarta neutrales, score < 0.65)
 
 
190
  Generacion de senal (Qwen3-8B Space / API directa)
191
  ↓
192
  Fallback: OpenRouter (deepseek-chat)
193
  ↓
194
  Fallback: Rule-based (precio del mercado)
195
  ↓
 
 
196
  Persistencia (SQLite) + Emision Socket.io
197
  ```
198
 
@@ -200,8 +266,8 @@ Persistencia (SQLite) + Emision Socket.io
200
 
201
  | Job | Frecuencia | Descripcion |
202
  |-----|-----------|-------------|
203
- | syncMarkets | Cada 30s | Sincroniza precios desde Polymarket Gamma API |
204
- | generateSignals | Cada 5 min | Genera senales IA para top 20 mercados activos |
205
  | updatePositionsPnL | Cada 30s | Recalcula P&L de posiciones abiertas |
206
  | processAlerts | Cada 60s | Revisa watchlist y envia alertas Telegram |
207
 
@@ -213,9 +279,9 @@ El frontend es una SPA construida con **Vite 7** como bundler y dev server.
213
 
214
  - **Estetica dark terminal / fintech:** paleta `#0a0c10`, tipografias `Syne` + `DM Mono`.
215
  - **Layout ajustable:** sidebar colapsable, paneles del dashboard colapsables individualmente.
216
- - **Mapa global interactivo:** Leaflet con burbujas por pais (tamano = volumen, color = senal IA).
217
- - **Panel de senales IA:** mercados top con badges alcista/bajista/neutral y barras de probabilidad.
218
- - **Detalle de mercado:** sparklines, historial de precios 7d, analisis IA y simulador de posiciones.
219
  - **Vistas adicionales:** Posiciones abiertas, Lista de seguimiento, Historial de alertas.
220
 
221
  ### Flujo de desarrollo
 
32
 
33
  El frontend **consume datos reales del backend** y tiene **fallback a datos mock** cuando el backend no responde (modo demo sin configuracion).
34
 
35
+ ## Mejoras de utilidad
36
+
37
+ Para que la app sea util mas alla de la demo visual, se han incorporado los siguientes ajustes β€” todos orientados a que las senales tengan *edge* real y no sean ruido bonito:
38
+
39
+ ### 1. Fetch diversificado por tag (anti-monotonia)
40
+
41
+ El endpoint `/markets` de Polymarket ignora `tag_id` y siempre devuelve la home feed (politica US + World Cup). El cliente usa ahora el endpoint **`/events`** que SI respeta `tag_id`, con un catalogo curado de ~25 tags de alto valor (crypto-prices, fed, stock-market, tech, openai, middle-east, oil-industry, europe, taiwan-election, etc.) y aplana los mercados por tag.
42
+
43
+ Resultado: ~1000 mercados activos diarios distribuidos en 6 categorias (cripto, economia, geopolitica, ciencia, politica, entretenimiento) en lugar de los ~100 dominados por una unica categoria.
44
+
45
+ ### 2. Whitelist de mercados analizables
46
+
47
+ `polymarket.client.js β†’ isAnalyzable()` flaggea como **no analizables** los mercados donde la IA no tiene edge plausible:
48
+
49
+ - Predicciones de palabras (*"Will Trump say nuclear?"*)
50
+ - Views de YouTubers, recuentos de tweets
51
+ - "Before GTA VI"-style memes
52
+ - Deportes y entretenimiento
53
+
54
+ `signals.service.js` salta la generacion para estos mercados y el frontend pinta el badge **"FUERA DE ALCANCE"** en lugar de fabricar confianza falsa. Asi, cada senal visible es defendible.
55
+
56
+ ### 3. Ground truth de cripto via CoinGecko
57
+
58
+ `utils/coingecko.client.js` resuelve spot prices (BTC, ETH, SOL, DOGE, ADA, XRP) y los inyecta en el prompt de la IA para mercados de precio objetivo:
59
+
60
+ ```
61
+ GROUND TRUTH: BTC spot $103,400. Target $150,000 (+45.1% required).
62
+ Use this to judge whether the implied probability is plausible given typical volatility.
63
+ ```
64
+
65
+ Cache TTL 60s β€” respeta el rate limit gratuito de CoinGecko.
66
+
67
+ ### 4. Edge gap explicito (impliedProb vs fairProb)
68
+
69
+ Cada `AISignal` persiste ahora `impliedProb`, `fairProb` y `edgePoints`. El pipeline mapea `(signal, confidence) β†’ fairProb`:
70
+
71
+ | Signal | Formula |
72
+ |--------|---------|
73
+ | bullish + conf 0.8 | fairProb = 0.5 + 0.8 Γ— 0.5 = 0.90 |
74
+ | bearish + conf 0.8 | fairProb = 0.5 βˆ’ 0.8 Γ— 0.5 = 0.10 |
75
+ | neutral | fairProb = 0.5 |
76
+
77
+ La tarjeta del mercado muestra: `Mercado 65% Β· IA 78% Β· Edge +13pp` β€” claim cuantitativo en lugar de prosa vaga.
78
+
79
+ ### 5. Spread-aware sizing (Kelly con costes)
80
+
81
+ Polymarket expone `spread`, `bestBid`, `bestAsk` por mercado. `positions/kelly.js β†’ suggestSize()` resta el spread del edge bruto antes de calcular el tamano de posicion:
82
+
83
+ ```
84
+ edgeNeto = |edgePoints/100| - spread
85
+ fraction = Quarter-Kelly(price, impliedProb + edgeNeto)
86
+ amount = bankroll * min(0.25, fraction)
87
+ ```
88
+
89
+ Mercados con `spread > 5Β’` se marcan como **ilΓ­quidos** y los botones de compra se desactivan. Endpoint publico: `GET /api/v1/positions/suggestion/:marketId`.
90
+
91
+ ### 6. Distribucion geografica del mapa
92
+
93
+ `map.js` usa **jitter determinista** (hash del marketId β†’ desplazamiento en bounding-box del pais) para que multiples mercados del mismo pais no se apilen sobre la capital. Mercados sin pais (cripto, indices, AI) se reparten entre **40 hubs financieros** globales (NYC, Sao Paulo, Mumbai, Lagos, Moscu, Yakarta, Sydney, etc.) en vez de caer todos sobre [20,0].
94
+
95
  ## Estructura
96
 
97
  ```
 
243
  ### Pipeline de IA
244
 
245
  ```
246
+ Whitelist analyzable (skip predicciones-de-palabras, sports, memes)
247
+ ↓
248
+ Noticias (Finnhub) β†’ relevantes por mercado
249
  ↓
250
  Filtrado (ModernFinBERT Space / API directa)
251
  ↓ (descarta neutrales, score < 0.65)
252
+ Ground truth crypto (CoinGecko spot β†’ solo si aplica)
253
+ ↓
254
  Generacion de senal (Qwen3-8B Space / API directa)
255
  ↓
256
  Fallback: OpenRouter (deepseek-chat)
257
  ↓
258
  Fallback: Rule-based (precio del mercado)
259
  ↓
260
+ Calculo edge: impliedProb vs fairProb β†’ edgePoints
261
+ ↓
262
  Persistencia (SQLite) + Emision Socket.io
263
  ```
264
 
 
266
 
267
  | Job | Frecuencia | Descripcion |
268
  |-----|-----------|-------------|
269
+ | syncMarkets | Cada 30s | Sincroniza precios + spread desde Polymarket Gamma (fetch diversificado por tag) |
270
+ | generateSignals | Cada 5 min | Genera senales IA para 40 mercados diversificados por categoria (solo analyzable=true) |
271
  | updatePositionsPnL | Cada 30s | Recalcula P&L de posiciones abiertas |
272
  | processAlerts | Cada 60s | Revisa watchlist y envia alertas Telegram |
273
 
 
279
 
280
  - **Estetica dark terminal / fintech:** paleta `#0a0c10`, tipografias `Syne` + `DM Mono`.
281
  - **Layout ajustable:** sidebar colapsable, paneles del dashboard colapsables individualmente.
282
+ - **Mapa global interactivo:** Leaflet con burbujas por pais (tamano = volumen, color = senal IA), jitter determinista para evitar apilamientos y 40 hubs financieros para mercados sin pais.
283
+ - **Panel de senales IA:** mercados con badges alcista/bajista/neutral, **fila de edge cuantitativa** (`Mercado X% Β· IA Y% Β· Edge Β±N pp`) y badge **"FUERA DE ALCANCE"** para mercados no analizables.
284
+ - **Detalle de mercado:** sparklines, historial 7d, analisis IA, simulador de posiciones con **sugerencia de tamano Quarter-Kelly cost-aware** (servida por `GET /positions/suggestion/:marketId`) y deshabilitacion automatica para mercados con spread > 5Β’.
285
  - **Vistas adicionales:** Posiciones abiertas, Lista de seguimiento, Historial de alertas.
286
 
287
  ### Flujo de desarrollo
backend/prisma/schema.prisma CHANGED
@@ -43,6 +43,11 @@ model Market {
43
  noPrice Float? // Precio NO: 0.0 a 1.0
44
  volumeEur Float? // Volumen en Eur
45
  liquidityEur Float? // Liquidez en Eur
 
 
 
 
 
46
  status String @default("active") // active | closed | resolved
47
  closesAt DateTime? // Fecha de cierre del mercado
48
  lastSynced DateTime @default(now()) // Ultima sincronizacion de precios
@@ -63,6 +68,9 @@ model AISignal {
63
  keyRisk String? // 1 frase de riesgo principal
64
  newsCount Int @default(0) // Titulares relevantes usados
65
  modelVersion String @default("Qwen3-8B") // Modelo LLM que genero la senal
 
 
 
66
  generatedAt DateTime @default(now())
67
 
68
  @@index([marketId, generatedAt])
 
43
  noPrice Float? // Precio NO: 0.0 a 1.0
44
  volumeEur Float? // Volumen en Eur
45
  liquidityEur Float? // Liquidez en Eur
46
+ spread Float? // Bid/ask spread (0-1, ej 0.02 = 2c)
47
+ bestBid Float? // Mejor oferta de compra
48
+ bestAsk Float? // Mejor oferta de venta
49
+ clobTokenId String? // YES outcome CLOB token ID (para prices-history)
50
+ analyzable Boolean @default(true) // Si la IA tiene edge plausible aqui
51
  status String @default("active") // active | closed | resolved
52
  closesAt DateTime? // Fecha de cierre del mercado
53
  lastSynced DateTime @default(now()) // Ultima sincronizacion de precios
 
68
  keyRisk String? // 1 frase de riesgo principal
69
  newsCount Int @default(0) // Titulares relevantes usados
70
  modelVersion String @default("Qwen3-8B") // Modelo LLM que genero la senal
71
+ impliedProb Float? // Probabilidad implicita YES al generar (0-1)
72
+ fairProb Float? // Probabilidad "justa" segun IA (0-1)
73
+ edgePoints Float? // (fairProb - impliedProb) * 100, signo conserva direccion
74
  generatedAt DateTime @default(now())
75
 
76
  @@index([marketId, generatedAt])
backend/src/finnhub/finnhub.service.js CHANGED
@@ -2,17 +2,26 @@
2
  * Logica de negocio del modulo de noticias Finnhub.
3
  *
4
  * Responsabilidades:
5
- * - fetchFinancialNews(daysBack) β†’ obtiene noticias generales de mercado.
6
- * - filterNewsByRelevance(news, question) β†’ filtra noticias por keywords
7
- * extraidos de la pregunta del mercado (matching simple case-insensitive).
8
- * - fetchHeadlinesForPipeline({ symbols, daysBack, limitPerSymbol })
9
- * β†’ agrega noticias de multiples simbolos para el pipeline de IA.
10
  *
11
- * Restricciones:
12
- * - Si FINNHUB_API_KEY no esta configurada, devuelve array vacio sin lanzar error
13
- * (el pipeline continua con rule-based).
14
- * - fetchHeadlinesForPipeline limita a maximo 5 simbolos por llamada
15
- * para respetar el rate limit de 60 llamadas/minuto.
 
 
 
 
 
 
 
 
 
 
16
  *
17
  * Consumido por:
18
  * - signals/aiPipeline.js β†’ fase 1 de filtrado de noticias.
@@ -21,15 +30,242 @@
21
  import { getCompanyNews, getMarketNews } from './finnhub.client.js';
22
  import { logger } from '../utils/logger.js';
23
 
24
- const MAX_SYMBOLS_PER_PIPELINE_CALL = 5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  /**
27
- * Extrae keywords significativas de una pregunta de mercado.
28
- * Elimina palabras comunes (stop words) y devuelve tokens unicos.
29
- *
30
- * @param {string} question
31
- * @returns {string[]}
32
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  function extractKeywords(question) {
34
  if (!question) return [];
35
  const stopWords = new Set([
@@ -47,7 +283,6 @@ function extractKeywords(question) {
47
  'too', 'very', 'just', 'now', 'then', 'here', 'there', 'once', 'again',
48
  'further', 'once', 'also', 'back', 'still', 'yet', 'already', 'almost',
49
  'quite', 'rather', 'really', 'soon', 'today', 'tomorrow', 'yesterday',
50
- // Espanol
51
  'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'pero',
52
  'en', 'de', 'a', 'por', 'para', 'con', 'sin', 'sobre', 'entre', 'durante',
53
  'antes', 'despues', 'durante', 'es', 'son', 'era', 'fue', 'ser', 'estar',
@@ -68,57 +303,184 @@ function extractKeywords(question) {
68
  .filter((w) => w.length > 2 && !stopWords.has(w));
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  /**
72
- * Filtra noticias por relevancia respecto a una pregunta de mercado.
73
  *
74
- * Comprueba si alguna keyword de la pregunta aparece en el headline o summary.
75
- * Es case-insensitive y ignora acentos basicos.
 
 
76
  *
77
- * @param {Object[]} news - Array de noticias normalizadas.
78
- * @param {string} question - Pregta del mercado.
79
- * @returns {Object[]} Noticias filtradas por relevancia.
80
  */
81
- export function filterNewsByRelevance(news, question) {
82
- const keywords = extractKeywords(question);
83
- if (keywords.length === 0) return news;
84
 
85
- const keywordSet = new Set(keywords);
86
- return news.filter((item) => {
87
- const text = `${item.headline} ${item.summary}`.toLowerCase();
88
- return Array.from(keywordSet).some((kw) => text.includes(kw));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  });
 
 
 
90
  }
91
 
92
  /**
93
- * Obtiene noticias generales de mercado de los ultimos N dias.
94
- *
95
- * @param {number} [daysBack=7]
96
- * @returns {Promise<Object[]>}
97
  */
98
- export async function fetchFinancialNews(daysBack = 7) {
99
- try {
100
- const general = await getMarketNews('general', 50);
101
- const forex = await getMarketNews('forex', 20);
102
- const crypto = await getMarketNews('crypto', 20);
103
- return [...general, ...forex, ...crypto];
104
- } catch (err) {
105
- logger.warn({ err: err.message }, 'fetchFinancialNews failed, returning empty array');
106
- return [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  }
 
 
108
  }
109
 
110
  /**
111
- * Obtiene titulares financieros para multiples simbolos,
112
- * adaptados para el pipeline de procesamiento de IA.
113
- *
114
- * Limita a maximo 5 simbolos por llamada para respetar el rate limit
115
- * de 60 llamadas/minuto de Finnhub (free tier).
116
  *
117
- * @param {Object} [params={}]
118
- * @param {string[]} [params.symbols=[]]
119
- * @param {number} [params.daysBack=7]
120
- * @param {number} [params.limitPerSymbol=20]
121
- * @returns {Promise<Object[]>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  */
123
  export async function fetchHeadlinesForPipeline({
124
  symbols = [],
@@ -127,23 +489,14 @@ export async function fetchHeadlinesForPipeline({
127
  } = {}) {
128
  if (!symbols.length) return [];
129
 
130
- const limitedSymbols = symbols.slice(0, MAX_SYMBOLS_PER_PIPELINE_CALL);
131
- if (symbols.length > MAX_SYMBOLS_PER_PIPELINE_CALL) {
132
- logger.warn(
133
- { requested: symbols.length, allowed: MAX_SYMBOLS_PER_PIPELINE_CALL },
134
- 'fetchHeadlinesForPipeline truncated symbols to respect rate limit',
135
- );
136
- }
137
-
138
- const promises = limitedSymbols.map(async (s) => {
139
  try {
140
- const items = await getCompanyNews(s, daysBack);
141
- return items.slice(0, limitPerSymbol).map((it) => ({ symbol: s, ...it }));
142
  } catch (err) {
143
- logger.warn({ err: err.message, symbol: s }, 'getCompanyNews failed for symbol');
144
- return [];
145
  }
146
- });
147
- const results = await Promise.all(promises);
148
- return results.flat();
149
  }
 
2
  * Logica de negocio del modulo de noticias Finnhub.
3
  *
4
  * Responsabilidades:
5
+ * - fetchFinancialNewsForMarket(market) β†’ obtiene noticias financieras
6
+ * RELEVANTES para un mercado especifico de Polymarket.
7
+ * - Mapeo inteligente de mercados a simbolos bursatiles (bitcoin β†’ BTC, Coinbase, etc.)
8
+ * - Deduplicacion, scoring de relevancia, y limitacion.
 
9
  *
10
+ * Filosofia:
11
+ * Finnhub cubre noticias financieras. Si un mercado de Polymarket NO tiene
12
+ * correlacion financiera directa (ej: GTA VI, Weinstein, deportes), NO enviamos
13
+ * noticias irrelevantes de bitcoin. En esos casos, Qwen analiza solo el precio
14
+ * del mercado y momentum.
15
+ *
16
+ * Si el mercado SI tiene correlacion financiera (bitcoin, crypto, stocks, forex),
17
+ * buscamos noticias especificas de los simbolos/sectores asociados usando
18
+ * company-news de Finnhub para mayor precision.
19
+ *
20
+ * Mejoras:
21
+ * - Cache TTL de 5 min para company-news por simbolo.
22
+ * - Deduplicacion por headline normalizado.
23
+ * - Maximo 8 noticias relevantes por mercado.
24
+ * - Shuffle para diversidad entre mercados.
25
  *
26
  * Consumido por:
27
  * - signals/aiPipeline.js β†’ fase 1 de filtrado de noticias.
 
30
  import { getCompanyNews, getMarketNews } from './finnhub.client.js';
31
  import { logger } from '../utils/logger.js';
32
 
33
+ const MAX_NEWS_PER_MARKET = 8;
34
+ const NEWS_CACHE_TTL_MS = 30 * 1000; // 30 segundos (noticias frescas)
35
+
36
+ // Cache por simbolo
37
+ const companyNewsCache = new Map(); // symbol β†’ { data, timestamp }
38
+ const generalNewsCache = { data: null, timestamp: 0 };
39
+
40
+ // ── Mapeo inteligente: mercados β†’ simbolos financieros ────────────────────────
41
+
42
+ const FINANCIAL_KEYWORDS = {
43
+ // Crypto
44
+ bitcoin: ['BTC', 'COIN', 'MSTR', 'RIOT', 'MARA'],
45
+ btc: ['BTC', 'COIN', 'MSTR'],
46
+ crypto: ['COIN', 'BTC', 'ETH', 'SOL', 'MSTR'],
47
+ ethereum: ['ETH', 'COIN'],
48
+ eth: ['ETH', 'COIN'],
49
+ solana: ['SOL', 'COIN'],
50
+ blockchain: ['COIN', 'BTC', 'IBM'],
51
+ altcoin: ['COIN', 'ETH', 'SOL'],
52
+ defi: ['COIN', 'ETH'],
53
+ stablecoin: ['COIN', 'CRCL'],
54
+
55
+ // Forex / Macro
56
+ dollar: ['DXY', 'UUP'],
57
+ euro: ['FXE'],
58
+ yen: ['FXY'],
59
+ pound: ['FXB'],
60
+ inflation: ['DXY', 'TLT', 'GLD'],
61
+ cpi: ['SPY', 'TLT', 'GLD'],
62
+ recession: ['SPY', 'QQQ', 'VIX', 'TLT'],
63
+ 'interest rate': ['TLT', 'TBT', 'DXY'],
64
+ 'rate cut': ['TLT', 'SPY', 'QQQ'],
65
+ 'rate hike': ['TBT', 'DXY'],
66
+ fed: ['TLT', 'SPY', 'DXY'],
67
+ powell: ['TLT', 'SPY'],
68
+ ecb: ['FXE', 'EWG'],
69
+ boj: ['FXY', 'EWJ'],
70
+ unemployment: ['SPY', 'TLT'],
71
+ gdp: ['SPY', 'DXY'],
72
+ tariff: ['SPY', 'FXI', 'XLI'],
73
+ 'trade war': ['FXI', 'SPY', 'GLD'],
74
+
75
+ // Tech / Stocks
76
+ apple: ['AAPL'],
77
+ tesla: ['TSLA'],
78
+ nvidia: ['NVDA'],
79
+ amazon: ['AMZN'],
80
+ google: ['GOOGL'],
81
+ microsoft: ['MSFT'],
82
+ meta: ['META'],
83
+ facebook: ['META'],
84
+ openai: ['MSFT', 'NVDA'],
85
+ anthropic: ['GOOGL', 'AMZN'],
86
+ 'artificial intelligence': ['NVDA', 'MSFT', 'GOOGL', 'META'],
87
+ ai: ['NVDA', 'MSFT', 'GOOGL', 'META'],
88
+ semiconductor: ['NVDA', 'TSM', 'AVGO', 'AMD'],
89
+ chip: ['NVDA', 'TSM', 'AVGO', 'AMD'],
90
+ spacex: ['TSLA', 'RKLB'],
91
+ starlink: ['TSLA'],
92
+ boeing: ['BA'],
93
+ airbus: ['EADSY'],
94
+
95
+ // Commodities
96
+ gold: ['GLD', 'IAU'],
97
+ silver: ['SLV'],
98
+ oil: ['USO', 'XLE'],
99
+ energy: ['XLE', 'USO'],
100
+ natgas: ['UNG'],
101
+ uranium: ['URA', 'CCJ'],
102
+
103
+ // Market indices
104
+ 's&p': ['SPY', 'SPX'],
105
+ sp500: ['SPY'],
106
+ nasdaq: ['QQQ'],
107
+ 'stock market': ['SPY', 'QQQ', 'VIX'],
108
+ vix: ['VIX', 'SPY'],
109
+
110
+ // Geopolitical risk (afecta mercados) - elections impact VIX/DXY/defense
111
+ war: ['GLD', 'USO', 'DXY', 'VIX', 'LMT', 'RTX'],
112
+ conflict: ['GLD', 'USO', 'VIX', 'LMT'],
113
+ invasion: ['GLD', 'USO', 'VIX', 'LMT'],
114
+ ceasefire: ['SPY', 'USO'],
115
+ sanctions: ['USO', 'GLD', 'DXY'],
116
+ iran: ['USO', 'GLD', 'LMT'],
117
+ israel: ['USO', 'GLD', 'LMT'],
118
+ gaza: ['USO', 'GLD'],
119
+ hamas: ['USO', 'GLD'],
120
+ hezbollah: ['USO', 'GLD'],
121
+ ukraine: ['USO', 'GLD', 'LMT', 'RTX'],
122
+ russia: ['USO', 'GLD', 'LMT'],
123
+ putin: ['USO', 'GLD', 'LMT'],
124
+ china: ['FXI', 'USO', 'SPY'],
125
+ xi: ['FXI', 'SPY'],
126
+ taiwan: ['TSM', 'FXI', 'LMT'],
127
+ 'north korea': ['LMT', 'EWY'],
128
+ venezuela: ['USO'],
129
+ saudi: ['USO', 'XLE'],
130
+ opec: ['USO', 'XLE'],
131
+
132
+ // Politica USA - mueve VIX/DXY/SPY
133
+ trump: ['SPY', 'DXY', 'VIX', 'XLE'],
134
+ biden: ['SPY', 'DXY', 'VIX'],
135
+ harris: ['SPY', 'VIX'],
136
+ desantis: ['SPY', 'XLE'],
137
+ vance: ['SPY', 'XLE'],
138
+ election: ['SPY', 'VIX', 'DXY'],
139
+ 'presidential election': ['SPY', 'VIX', 'DXY', 'GLD'],
140
+ republican: ['SPY', 'XLE', 'LMT'],
141
+ democrat: ['SPY', 'TLT'],
142
+ congress: ['SPY', 'TLT'],
143
+ senate: ['SPY', 'TLT'],
144
+ shutdown: ['SPY', 'TLT', 'VIX'],
145
+ 'debt ceiling': ['TLT', 'GLD', 'DXY'],
146
+ impeach: ['SPY', 'VIX'],
147
+ scotus: ['SPY'],
148
+ 'supreme court': ['SPY'],
149
+
150
+ // Politica internacional
151
+ macron: ['EWQ', 'FXE'],
152
+ 'le pen': ['EWQ', 'FXE'],
153
+ merkel: ['EWG', 'FXE'],
154
+ scholz: ['EWG', 'FXE'],
155
+ meloni: ['EWI', 'FXE'],
156
+ starmer: ['EWU', 'FXB'],
157
+ milei: ['ARGT'],
158
+ lula: ['EWZ'],
159
+ bolsonaro: ['EWZ'],
160
+ erdogan: ['TUR'],
161
+ modi: ['INDA'],
162
+ zelensky: ['USO', 'LMT'],
163
+ netanyahu: ['USO', 'GLD'],
164
+
165
+ // Entretenimiento con accionables financieros
166
+ netflix: ['NFLX'],
167
+ disney: ['DIS'],
168
+ marvel: ['DIS'],
169
+ spotify: ['SPOT'],
170
+ paramount: ['PARA'],
171
+ warner: ['WBD'],
172
+ 'box office': ['DIS', 'WBD', 'NFLX'],
173
+ oscar: ['NFLX', 'DIS', 'WBD'],
174
+ gta: ['TTWO'],
175
+ rockstar: ['TTWO'],
176
+ 'video game': ['TTWO', 'EA', 'ATVI'],
177
+
178
+ // Salud / pandemia
179
+ vaccine: ['PFE', 'MRNA', 'JNJ'],
180
+ fda: ['PFE', 'MRNA', 'LLY'],
181
+ pandemic: ['PFE', 'MRNA', 'VIX'],
182
+ 'avian flu': ['PFE', 'MRNA'],
183
+ ozempic: ['NVO', 'LLY'],
184
+
185
+ // Clima/eventos
186
+ hurricane: ['XLE', 'HD', 'LOW'],
187
+ climate: ['ICLN', 'TAN', 'XLE'],
188
+ };
189
 
190
  /**
191
+ * Detecta si una pregunta de mercado tiene correlacion financiera
192
+ * y devuelve los simbolos bursatiles relevantes.
 
 
 
193
  */
194
+ function extractFinancialSymbols(question) {
195
+ if (!question) return { hasFinancialCorrelation: false, symbols: [] };
196
+
197
+ const lower = question.toLowerCase();
198
+ const matchedSymbols = new Set();
199
+ const matchedKeywords = [];
200
+
201
+ for (const [keyword, symbols] of Object.entries(FINANCIAL_KEYWORDS)) {
202
+ // Match palabra completa o substring
203
+ const regex = new RegExp(`\\b${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b|${keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'i');
204
+ if (regex.test(lower)) {
205
+ symbols.forEach((s) => matchedSymbols.add(s));
206
+ matchedKeywords.push(keyword);
207
+ }
208
+ }
209
+
210
+ return {
211
+ hasFinancialCorrelation: matchedSymbols.size > 0,
212
+ symbols: Array.from(matchedSymbols),
213
+ keywords: matchedKeywords,
214
+ };
215
+ }
216
+
217
+ // ── Helpers ───────────────────────────────────────────────────────────────────
218
+
219
+ function normalizeText(text) {
220
+ if (!text) return '';
221
+ return text
222
+ .toLowerCase()
223
+ .replace(/[^\w\s]/g, ' ')
224
+ .replace(/\s+/g, ' ')
225
+ .trim();
226
+ }
227
+
228
+ function deduplicateNews(news) {
229
+ const seen = new Set();
230
+ return news.filter((item) => {
231
+ const normalized = normalizeText(item.headline);
232
+ if (!normalized || seen.has(normalized)) return false;
233
+ seen.add(normalized);
234
+ return true;
235
+ });
236
+ }
237
+
238
+ function shuffleArray(array) {
239
+ const arr = [...array];
240
+ for (let i = arr.length - 1; i > 0; i--) {
241
+ const j = Math.floor(Math.random() * (i + 1));
242
+ [arr[i], arr[j]] = [arr[j], arr[i]];
243
+ }
244
+ return arr;
245
+ }
246
+
247
+ function scoreRelevance(item, marketKeywords) {
248
+ if (!marketKeywords.length) return 0;
249
+
250
+ const textHeadline = normalizeText(item.headline);
251
+ const textSummary = normalizeText(item.summary);
252
+ let score = 0;
253
+
254
+ for (const kw of marketKeywords) {
255
+ if (textHeadline.includes(kw)) score += 3;
256
+ if (textSummary.includes(kw)) score += 1;
257
+ }
258
+
259
+ // Bonus recencia
260
+ if (item.datetime) {
261
+ const ageHours = (Date.now() - new Date(item.datetime).getTime()) / (1000 * 60 * 60);
262
+ if (ageHours < 6) score += 2;
263
+ else if (ageHours < 24) score += 1;
264
+ }
265
+
266
+ return score;
267
+ }
268
+
269
  function extractKeywords(question) {
270
  if (!question) return [];
271
  const stopWords = new Set([
 
283
  'too', 'very', 'just', 'now', 'then', 'here', 'there', 'once', 'again',
284
  'further', 'once', 'also', 'back', 'still', 'yet', 'already', 'almost',
285
  'quite', 'rather', 'really', 'soon', 'today', 'tomorrow', 'yesterday',
 
286
  'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'pero',
287
  'en', 'de', 'a', 'por', 'para', 'con', 'sin', 'sobre', 'entre', 'durante',
288
  'antes', 'despues', 'durante', 'es', 'son', 'era', 'fue', 'ser', 'estar',
 
303
  .filter((w) => w.length > 2 && !stopWords.has(w));
304
  }
305
 
306
+ // ── Cache helpers ─────────────────────────────────────────────────────────────
307
+
308
+ function getCachedCompanyNews(symbol) {
309
+ const cached = companyNewsCache.get(symbol);
310
+ if (cached && Date.now() - cached.timestamp < NEWS_CACHE_TTL_MS) {
311
+ return cached.data;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ function setCachedCompanyNews(symbol, data) {
317
+ companyNewsCache.set(symbol, { data, timestamp: Date.now() });
318
+ }
319
+
320
+ function getCachedGeneralNews() {
321
+ if (generalNewsCache.data && Date.now() - generalNewsCache.timestamp < NEWS_CACHE_TTL_MS) {
322
+ return generalNewsCache.data;
323
+ }
324
+ return null;
325
+ }
326
+
327
+ function setCachedGeneralNews(data) {
328
+ generalNewsCache.data = data;
329
+ generalNewsCache.timestamp = Date.now();
330
+ }
331
+
332
+ // ── Public API ────────────────────────────────────────────────────────────────
333
+
334
  /**
335
+ * Obtiene noticias financieras RELEVANTES para un mercado especifico.
336
  *
337
+ * Estrategia:
338
+ * 1. Detecta si el mercado tiene correlacion financiera (bitcoin, crypto, etc.)
339
+ * 2. Si SI β†’ busca company-news de los simbolos asociados (muy relevantes)
340
+ * 3. Si NO β†’ devuelve array vacio (Qwen analizara solo el precio)
341
  *
342
+ * @param {Object} market - Mercado de Polymarket ({ question })
343
+ * @returns {Promise<Object[]>} Noticias relevantes o array vacio.
 
344
  */
345
+ export async function fetchFinancialNewsForMarket(market) {
346
+ if (!market || !market.question) return [];
 
347
 
348
+ const { hasFinancialCorrelation, symbols, keywords } = extractFinancialSymbols(market.question);
349
+
350
+ // Si NO hay correlacion financiera directa, usamos noticias macro generales
351
+ // como contexto (mejor que dejar a la IA sin informacion alguna).
352
+ if (!hasFinancialCorrelation) {
353
+ logger.debug({ marketId: market.id, question: market.question }, 'No financial correlation, using general macro news as context');
354
+ return fetchGeneralMarketNews(15);
355
+ }
356
+
357
+ logger.info({ marketId: market.id, symbols, keywords }, 'Fetching company news for financial market');
358
+
359
+ // Obtener noticias de cada simbolo (con cache)
360
+ const allNews = [];
361
+ // Fechas dinamicas: alternar entre 1-3 dias para variedad
362
+ const daysBack = 1 + Math.floor(Math.random() * 2); // 1 o 2 dias
363
+
364
+ for (const symbol of symbols.slice(0, 5)) {
365
+ let news = getCachedCompanyNews(symbol);
366
+ if (!news) {
367
+ try {
368
+ news = await getCompanyNews(symbol, daysBack);
369
+ setCachedCompanyNews(symbol, news);
370
+ } catch (err) {
371
+ logger.warn({ err: err.message, symbol }, 'Failed to fetch company news');
372
+ news = [];
373
+ }
374
+ }
375
+ allNews.push(...news);
376
+ }
377
+
378
+ // Complementar con noticias generales del mercado para mas variedad
379
+ try {
380
+ const general = await fetchGeneralMarketNews(10);
381
+ allNews.push(...general);
382
+ } catch (e) { /* ignore */ }
383
+
384
+ // Deduplicar y ordenar por recencia
385
+ const deduped = deduplicateNews(allNews);
386
+ deduped.sort((a, b) => {
387
+ const da = a.datetime ? new Date(a.datetime).getTime() : 0;
388
+ const db = b.datetime ? new Date(b.datetime).getTime() : 0;
389
+ return db - da;
390
  });
391
+
392
+ logger.info({ marketId: market.id, count: deduped.length, symbols }, 'Company news fetched');
393
+ return deduped;
394
  }
395
 
396
  /**
397
+ * Obtiene noticias generales de mercado (macro).
398
+ * Rota entre multiples categorias para variedad.
 
 
399
  */
400
+ export async function fetchGeneralMarketNews(limit = 20) {
401
+ let news = getCachedGeneralNews();
402
+ if (!news) {
403
+ try {
404
+ // Rotar categorias para variedad cada vez
405
+ const categories = ['general', 'forex', 'crypto', 'merger'];
406
+ const shuffledCats = shuffleArray(categories);
407
+
408
+ const results = await Promise.all(
409
+ shuffledCats.map((cat) => getMarketNews(cat, 25))
410
+ );
411
+
412
+ news = deduplicateNews(results.flat());
413
+ news.sort((a, b) => {
414
+ const da = a.datetime ? new Date(a.datetime).getTime() : 0;
415
+ const db = b.datetime ? new Date(b.datetime).getTime() : 0;
416
+ return db - da;
417
+ });
418
+ setCachedGeneralNews(news);
419
+ } catch (err) {
420
+ logger.warn({ err: err.message }, 'Failed to fetch general market news');
421
+ return [];
422
+ }
423
  }
424
+ // Devolver subconjunto aleatorio para variedad entre mercados
425
+ return shuffleArray(news).slice(0, limit);
426
  }
427
 
428
  /**
429
+ * Filtra noticias por relevancia respecto a una pregunta de mercado.
 
 
 
 
430
  *
431
+ * @param {Object[]} news - Array de noticias normalizadas.
432
+ * @param {string} question - Pregunta del mercado.
433
+ * @returns {Object[]} Noticias filtradas por relevancia (max MAX_NEWS_PER_MARKET).
434
+ */
435
+ export function filterNewsByRelevance(news, question) {
436
+ if (!news.length || !question) return [];
437
+
438
+ const keywords = extractKeywords(question);
439
+ if (keywords.length === 0) {
440
+ return shuffleArray(news).slice(0, MAX_NEWS_PER_MARKET);
441
+ }
442
+
443
+ const scored = news.map((item) => ({
444
+ item,
445
+ score: scoreRelevance(item, keywords),
446
+ }));
447
+
448
+ const relevant = scored
449
+ .filter((s) => s.score > 0)
450
+ .sort((a, b) => b.score - a.score)
451
+ .map((s) => s.item);
452
+
453
+ // Si hay muy pocas relevantes, complementar
454
+ if (relevant.length < 3) {
455
+ const usedIds = new Set(relevant.map((n) => n.id));
456
+ const extra = shuffleArray(news.filter((n) => !usedIds.has(n.id))).slice(0, MAX_NEWS_PER_MARKET - relevant.length);
457
+ return [...relevant, ...extra].slice(0, MAX_NEWS_PER_MARKET);
458
+ }
459
+
460
+ return shuffleArray(relevant.slice(0, MAX_NEWS_PER_MARKET * 2)).slice(0, MAX_NEWS_PER_MARKET);
461
+ }
462
+
463
+ /**
464
+ * Invalida el cache de noticias.
465
+ */
466
+ export function invalidateNewsCache() {
467
+ companyNewsCache.clear();
468
+ generalNewsCache.data = null;
469
+ generalNewsCache.timestamp = 0;
470
+ logger.info('News cache invalidated');
471
+ }
472
+
473
+ /**
474
+ * Legacy: obtiene noticias financieras generales (sin correlacion por mercado).
475
+ * @deprecated Usar fetchFinancialNewsForMarket(market) en su lugar.
476
+ */
477
+ export async function fetchFinancialNews() {
478
+ return fetchGeneralMarketNews(30);
479
+ }
480
+
481
+ /**
482
+ * Obtiene titulares financieros para multiples simbolos.
483
+ * @deprecated Usar fetchFinancialNewsForMarket(market) en su lugar.
484
  */
485
  export async function fetchHeadlinesForPipeline({
486
  symbols = [],
 
489
  } = {}) {
490
  if (!symbols.length) return [];
491
 
492
+ const allNews = [];
493
+ for (const symbol of symbols.slice(0, 5)) {
 
 
 
 
 
 
 
494
  try {
495
+ const items = await getCompanyNews(symbol, daysBack);
496
+ allNews.push(...items.slice(0, limitPerSymbol).map((it) => ({ symbol, ...it })));
497
  } catch (err) {
498
+ logger.warn({ err: err.message, symbol }, 'getCompanyNews failed for symbol');
 
499
  }
500
+ }
501
+ return allNews;
 
502
  }
backend/src/markets/markets.controller.js CHANGED
@@ -27,4 +27,10 @@ export const marketsController = {
27
  const market = await marketsService.getById(req.params.id);
28
  ok(res, market);
29
  },
 
 
 
 
 
 
30
  };
 
27
  const market = await marketsService.getById(req.params.id);
28
  ok(res, market);
29
  },
30
+
31
+ async getPriceHistory(req, res) {
32
+ const { interval } = req.query;
33
+ const history = await marketsService.getPriceHistory(req.params.id, interval);
34
+ ok(res, history);
35
+ },
36
  };
backend/src/markets/markets.repository.js CHANGED
@@ -17,9 +17,12 @@ export const marketsRepository = {
17
  findMany({ limit, offset, category, status }) {
18
  const where = { status };
19
  if (category) where.category = category;
 
 
 
20
  return prisma.market.findMany({
21
  where,
22
- orderBy: { volumeEur: 'desc' },
23
  take: limit,
24
  skip: offset,
25
  });
@@ -42,4 +45,76 @@ export const marketsRepository = {
42
  create: market,
43
  });
44
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  };
 
17
  findMany({ limit, offset, category, status }) {
18
  const where = { status };
19
  if (category) where.category = category;
20
+ // Ordenamos por liquidez (lo realmente tradeable AHORA) y luego volumen
21
+ // como desempate. Esto evita que los whales politicos historicos copen
22
+ // siempre la primera pagina.
23
  return prisma.market.findMany({
24
  where,
25
+ orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
26
  take: limit,
27
  skip: offset,
28
  });
 
45
  create: market,
46
  });
47
  },
48
+
49
+ /**
50
+ * Marca como 'closed' los mercados activos que NO esten en la lista de IDs.
51
+ * Se invoca despues de un sync para purgar mercados que ya no aparecen
52
+ * en el fetch curado por tags (evita mostrar restos de syncs anteriores).
53
+ *
54
+ * @param {string[]} activeIds - IDs presentes en el ultimo sync.
55
+ * @returns {Promise<number>} Numero de mercados marcados como cerrados.
56
+ */
57
+ async deactivateStale(activeIds) {
58
+ const result = await prisma.market.updateMany({
59
+ where: {
60
+ id: { notIn: activeIds },
61
+ status: 'active',
62
+ },
63
+ data: { status: 'closed' },
64
+ });
65
+ return result.count;
66
+ },
67
+
68
+ /**
69
+ * Selecciona un conjunto diversificado de mercados activos, ponderado por
70
+ * liquidez+volumen y distribuido entre categorias de alto valor accionable.
71
+ *
72
+ * Categorias prioritarias (peso = numero de mercados pedidos):
73
+ * - cripto, economΓ­a, geopolΓ­tica β†’ mayor peso (mas alpha financiero)
74
+ * - polΓ­tica, ciencia β†’ peso medio
75
+ * - entretenimiento, deportes, general β†’ peso bajo (relleno si sobra)
76
+ *
77
+ * @param {number} total - Numero total deseado (default 40).
78
+ * @returns {Promise<Market[]>}
79
+ */
80
+ async findDiversified(total = 40) {
81
+ const weights = {
82
+ 'cripto': 0.20,
83
+ 'economΓ­a': 0.18,
84
+ 'geopolΓ­tica': 0.18,
85
+ 'polΓ­tica': 0.14,
86
+ 'ciencia': 0.12,
87
+ 'entretenimiento': 0.08,
88
+ 'deportes': 0.05,
89
+ 'general': 0.05,
90
+ };
91
+
92
+ const slices = await Promise.all(
93
+ Object.entries(weights).map(async ([category, weight]) => {
94
+ const take = Math.max(1, Math.round(total * weight));
95
+ // Score: ordenamos por liquidez DESC para priorizar mercados tradeables
96
+ return prisma.market.findMany({
97
+ where: { status: 'active', category },
98
+ orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
99
+ take,
100
+ });
101
+ }),
102
+ );
103
+
104
+ const picked = slices.flat();
105
+ const seen = new Set(picked.map((m) => m.id));
106
+
107
+ // Si no llegamos al total (categoria vacia), rellenamos con los mas liquidos restantes
108
+ if (picked.length < total) {
109
+ const remaining = total - picked.length;
110
+ const filler = await prisma.market.findMany({
111
+ where: { status: 'active', id: { notIn: Array.from(seen) } },
112
+ orderBy: [{ liquidityEur: 'desc' }, { volumeEur: 'desc' }],
113
+ take: remaining,
114
+ });
115
+ picked.push(...filler);
116
+ }
117
+
118
+ return picked.slice(0, total);
119
+ },
120
  };
backend/src/markets/markets.routes.js CHANGED
@@ -16,6 +16,7 @@ import { listQuery, idParam } from './markets.validators.js';
16
  const router = Router();
17
 
18
  router.get('/', validate(listQuery, 'query'), marketsController.list);
 
19
  router.get('/:id', validate(idParam, 'params'), marketsController.getById);
20
 
21
  export default router;
 
16
  const router = Router();
17
 
18
  router.get('/', validate(listQuery, 'query'), marketsController.list);
19
+ router.get('/:id/history', validate(idParam, 'params'), marketsController.getPriceHistory);
20
  router.get('/:id', validate(idParam, 'params'), marketsController.getById);
21
 
22
  export default router;
backend/src/markets/markets.service.js CHANGED
@@ -15,9 +15,20 @@
15
 
16
  import { HttpError } from '../utils/apiResponse.js';
17
  import { marketsRepository } from './markets.repository.js';
 
 
 
18
 
19
  export const marketsService = {
20
  async list(query) {
 
 
 
 
 
 
 
 
21
  const [data, total] = await Promise.all([
22
  marketsRepository.findMany(query),
23
  marketsRepository.count(query),
@@ -30,4 +41,15 @@ export const marketsService = {
30
  if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
31
  return market;
32
  },
 
 
 
 
 
 
 
 
 
 
 
33
  };
 
15
 
16
  import { HttpError } from '../utils/apiResponse.js';
17
  import { marketsRepository } from './markets.repository.js';
18
+ import { httpGet } from '../utils/httpClient.js';
19
+
20
+ const CLOB_BASE = 'https://clob.polymarket.com';
21
 
22
  export const marketsService = {
23
  async list(query) {
24
+ // Si no hay filtro de categoria y es la primera pagina, devolvemos un mix
25
+ // diversificado entre categorias (mejor UX que la pagina mono-categoria
26
+ // que sale al ordenar por liquidez DESC).
27
+ if (!query.category && query.offset === 0 && query.status === 'active') {
28
+ const diversified = await marketsRepository.findDiversified(query.limit);
29
+ const total = await marketsRepository.count(query);
30
+ return { data: diversified, total };
31
+ }
32
  const [data, total] = await Promise.all([
33
  marketsRepository.findMany(query),
34
  marketsRepository.count(query),
 
41
  if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
42
  return market;
43
  },
44
+
45
+ async getPriceHistory(id, interval = '1w') {
46
+ const market = await marketsRepository.findById(id);
47
+ if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
48
+ if (!market.clobTokenId) throw new HttpError(404, 'NO_CLOB_TOKEN', 'Price history not available for this market');
49
+
50
+ const fidelity = interval === '1d' ? 60 : 1440;
51
+ const url = `${CLOB_BASE}/prices-history?market=${market.clobTokenId}&interval=${interval}&fidelity=${fidelity}`;
52
+ const data = await httpGet(url);
53
+ return data.history ?? [];
54
+ },
55
  };
backend/src/markets/markets.validators.js CHANGED
@@ -12,9 +12,10 @@
12
  import { z } from 'zod';
13
 
14
  export const listQuery = z.object({
15
- limit: z.coerce.number().int().min(1).max(100).default(20),
16
  offset: z.coerce.number().int().min(0).default(0),
17
- category: z.enum(['politics', 'crypto', 'economics', 'sports']).optional(),
 
18
  status: z.enum(['active', 'closed', 'resolved']).default('active'),
19
  });
20
 
 
12
  import { z } from 'zod';
13
 
14
  export const listQuery = z.object({
15
+ limit: z.coerce.number().int().min(1).max(200).default(60),
16
  offset: z.coerce.number().int().min(0).default(0),
17
+ // Acepta cualquier categoria (las del DB estan en espanol y son dinamicas).
18
+ category: z.string().optional(),
19
  status: z.enum(['active', 'closed', 'resolved']).default('active'),
20
  });
21
 
backend/src/markets/polymarket.client.js CHANGED
@@ -15,11 +15,66 @@
15
  */
16
 
17
  import { httpGet } from '../utils/httpClient.js';
 
18
 
19
- const GAMMA_URL = 'https://gamma-api.polymarket.com/markets';
 
20
  const USD_TO_EUR = 0.93;
21
 
22
- function mapStatus({ active, closed, archived }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  if (archived) return 'resolved';
24
  if (closed) return 'closed';
25
  return 'active';
@@ -187,28 +242,156 @@ function inferCountryCode(question, eventTitle = '') {
187
  return null;
188
  }
189
 
190
- export function mapMarket(raw) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
192
- const eventTitle = raw.events?.[0]?.title || '';
193
  const question = raw.question || '';
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  return {
196
  id: String(raw.id),
197
  question,
198
- category: inferCategory(question, eventTitle),
199
- countryCode: inferCountryCode(question, eventTitle),
200
  yesPrice,
201
  noPrice,
202
  volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
203
  liquidityEur: raw.liquidity != null ? parseFloat(raw.liquidity) * USD_TO_EUR : null,
 
 
 
 
 
204
  status: mapStatus(raw),
205
  closesAt: raw.endDate ? new Date(raw.endDate) : null,
206
  lastSynced: new Date(),
207
  };
208
  }
209
 
210
- export async function fetchActiveMarkets(limit = 100) {
211
- const url = `${GAMMA_URL}?active=true&closed=false&limit=${limit}`;
212
- const raw = await httpGet(url);
213
- return raw.map(mapMarket);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  }
 
15
  */
16
 
17
  import { httpGet } from '../utils/httpClient.js';
18
+ import { logger } from '../utils/logger.js';
19
 
20
+ const GAMMA_MARKETS_URL = 'https://gamma-api.polymarket.com/markets';
21
+ const GAMMA_EVENTS_URL = 'https://gamma-api.polymarket.com/events';
22
  const USD_TO_EUR = 0.93;
23
 
24
+ /**
25
+ * Tags de Polymarket que usamos para fetching diversificado.
26
+ * El endpoint /markets IGNORA tag_id, pero /events lo respeta correctamente.
27
+ * Cada tag aporta N mercados al pool global - el weight controla cuantos.
28
+ *
29
+ * Tags verificados manualmente desde gamma-api.polymarket.com/tags.
30
+ */
31
+ const TAG_SLICES = [
32
+ // Financieros directos (mayor peso - mas alpha)
33
+ { tagId: 1312, slug: 'crypto-prices', category: 'cripto', limit: 25 },
34
+ { tagId: 602, slug: 'stock-market', category: 'economΓ­a', limit: 25 },
35
+ { tagId: 159, slug: 'fed', category: 'economΓ­a', limit: 15 },
36
+ { tagId: 992, slug: 'labor-market', category: 'economΓ­a', limit: 10 },
37
+ { tagId: 1562, slug: 'market-caps', category: 'economΓ­a', limit: 10 },
38
+
39
+ // Tech / AI (alta accionabilidad)
40
+ { tagId: 1401, slug: 'tech', category: 'ciencia', limit: 20 },
41
+ { tagId: 22, slug: 'technology', category: 'ciencia', limit: 15 },
42
+ { tagId: 101999, slug: 'big-tech', category: 'ciencia', limit: 15 },
43
+ { tagId: 537, slug: 'openai', category: 'ciencia', limit: 10 },
44
+
45
+ // Geopolitica (afecta oil/gold/defense)
46
+ { tagId: 154, slug: 'middle-east', category: 'geopolΓ­tica', limit: 20 },
47
+ { tagId: 78, slug: 'iran', category: 'geopolΓ­tica', limit: 10 },
48
+ { tagId: 180, slug: 'israel', category: 'geopolΓ­tica', limit: 10 },
49
+ { tagId: 114, slug: 'syria', category: 'geopolΓ­tica', limit: 10 },
50
+ { tagId: 172, slug: 'oil-industry', category: 'economΓ­a', limit: 15 },
51
+ { tagId: 248, slug: 'energy-industry', category: 'economΓ­a', limit: 10 },
52
+
53
+ // Regional coverage (variedad geografica)
54
+ { tagId: 100410, slug: 'europe', category: 'geopolΓ­tica', limit: 20 },
55
+ { tagId: 167, slug: 'argentina', category: 'geopolΓ­tica', limit: 10 },
56
+ { tagId: 872, slug: 'pakistan', category: 'geopolΓ­tica', limit: 10 },
57
+ { tagId: 525, slug: 'netherlands', category: 'geopolΓ­tica', limit: 5 },
58
+ { tagId: 258, slug: 'taiwan-election', category: 'geopolΓ­tica', limit: 8 },
59
+ { tagId: 104846, slug: 'uk-elections', category: 'polΓ­tica', limit: 8 },
60
+ { tagId: 103388, slug: 'thailand-election', category: 'geopolΓ­tica', limit: 5 },
61
+ { tagId: 104090, slug: 'french-mayoral', category: 'polΓ­tica', limit: 5 },
62
+ { tagId: 104968, slug: 'mexico-election', category: 'polΓ­tica', limit: 8 },
63
+ { tagId: 103219, slug: 'bolivia-election', category: 'polΓ­tica', limit: 5 },
64
+
65
+ // Politica (peso moderado - es donde hay mas volumen)
66
+ { tagId: 2, slug: 'politics', category: 'polΓ­tica', limit: 15 },
67
+ { tagId: 789, slug: 'us-politics', category: 'polΓ­tica', limit: 10 },
68
+ { tagId: 126, slug: 'trump', category: 'polΓ­tica', limit: 10 },
69
+
70
+ // Corporativo / clima / cultura
71
+ { tagId: 550, slug: 'corporate-news', category: 'economΓ­a', limit: 15 },
72
+ { tagId: 102890, slug: 'climate-change', category: 'ciencia', limit: 10 },
73
+ { tagId: 596, slug: 'pop-culture', category: 'entretenimiento', limit: 10 },
74
+ { tagId: 100451, slug: 'breaking', category: 'general', limit: 15 },
75
+ ];
76
+
77
+ function mapStatus({ closed, archived }) {
78
  if (archived) return 'resolved';
79
  if (closed) return 'closed';
80
  return 'active';
 
242
  return null;
243
  }
244
 
245
+ /**
246
+ * Determina si una pregunta de mercado es analizable por la IA con edge plausible.
247
+ *
248
+ * Excluimos:
249
+ * - "Will X say WORD by date" (sin base de datos predictiva)
250
+ * - "Mentions" / "first to say" markets
251
+ * - Mercados de views/views-counts de YouTubers
252
+ * - "Before GTA VI" type meme markets
253
+ *
254
+ * Mantenemos:
255
+ * - Precios objetivo (Bitcoin $X by Y)
256
+ * - Decisiones Fed/ECB/BOE
257
+ * - Eventos geopoliticos concretos (acuerdos, sanciones, elecciones)
258
+ * - Cifras macro (CPI, GDP, employment)
259
+ */
260
+ function isAnalyzable(question, category) {
261
+ const q = question.toLowerCase();
262
+
263
+ // Patrones de mercados NO analizables (memes, predicciones-de-palabras, views)
264
+ const blacklist = [
265
+ /\bsay\b.*\?$/i, // "Will Trump say X?"
266
+ /how many.*tweet/i, // tweets count
267
+ /\bmentions?\b/i,
268
+ /\b# of tweets\b/i,
269
+ /views? on day/i, // MrBeast views
270
+ /views? in /i,
271
+ /before gta/i, // GTA VI memes
272
+ /jesus christ return/i,
273
+ /alien/i,
274
+ /\bpoll(ed|ing) (above|below)/i, // detailed polling minutiae
275
+ /first to/i, // "first to reach X"
276
+ /wear (a|the)/i, // clothing predictions
277
+ /shave/i,
278
+ /grammy|oscar|emmy/i, // award shows (subjective)
279
+ ];
280
+
281
+ if (blacklist.some((re) => re.test(q))) return false;
282
+
283
+ // Categorias inherentemente no analizables sin modelo dedicado
284
+ if (category === 'deportes') return false;
285
+ if (category === 'entretenimiento') return false;
286
+
287
+ return true;
288
+ }
289
+
290
+ export function mapMarket(raw, { eventTitle = '', tagCategory = null } = {}) {
291
  const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
292
+ const evTitle = eventTitle || raw.events?.[0]?.title || '';
293
  const question = raw.question || '';
294
 
295
+ // Prioriza categoria del tag (proviene de Polymarket directamente)
296
+ // sobre el matcher por keywords, que es ruidoso.
297
+ const category = tagCategory || inferCategory(question, evTitle);
298
+
299
+ const spread = raw.spread != null ? parseFloat(raw.spread) : null;
300
+ const bestBid = raw.bestBid != null ? parseFloat(raw.bestBid) : null;
301
+ const bestAsk = raw.bestAsk != null ? parseFloat(raw.bestAsk) : null;
302
+
303
+ let clobTokenId = null;
304
+ try {
305
+ const tokens = JSON.parse(raw.clobTokenIds || '[]');
306
+ clobTokenId = tokens[0] ?? null;
307
+ } catch { /* ignorar */ }
308
+
309
  return {
310
  id: String(raw.id),
311
  question,
312
+ category,
313
+ countryCode: inferCountryCode(question, evTitle),
314
  yesPrice,
315
  noPrice,
316
  volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
317
  liquidityEur: raw.liquidity != null ? parseFloat(raw.liquidity) * USD_TO_EUR : null,
318
+ spread,
319
+ bestBid,
320
+ bestAsk,
321
+ clobTokenId,
322
+ analyzable: isAnalyzable(question, category),
323
  status: mapStatus(raw),
324
  closesAt: raw.endDate ? new Date(raw.endDate) : null,
325
  lastSynced: new Date(),
326
  };
327
  }
328
 
329
+ /**
330
+ * Obtiene eventos de Polymarket filtrados por tag_id ordenados por volumen 24h.
331
+ * Devuelve los markets aplanados de cada evento.
332
+ */
333
+ async function fetchEventsByTag(tagId, limit) {
334
+ const url = `${GAMMA_EVENTS_URL}?active=true&closed=false&archived=false&tag_id=${tagId}&order=volume24hr&ascending=false&limit=${limit}`;
335
+ try {
336
+ const events = await httpGet(url);
337
+ if (!Array.isArray(events)) return [];
338
+ const flatMarkets = [];
339
+ for (const ev of events) {
340
+ const evTitle = ev.title || '';
341
+ for (const m of ev.markets || []) {
342
+ // Filtra mercados no activos a nivel de market
343
+ if (m.closed || m.archived || m.active === false) continue;
344
+ flatMarkets.push({ raw: m, eventTitle: evTitle });
345
+ }
346
+ }
347
+ return flatMarkets;
348
+ } catch (err) {
349
+ logger.warn({ err: err.message, tagId }, 'fetchEventsByTag failed');
350
+ return [];
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Obtiene mercados activos de Polymarket de forma DIVERSIFICADA por tag.
356
+ *
357
+ * Problema previo: el endpoint /markets ignora tag_id y devuelve siempre la
358
+ * misma "home feed" de Polymarket (dominada por US politics + World Cup).
359
+ *
360
+ * Solucion: iteramos sobre el endpoint /events (que SI respeta tag_id) con
361
+ * una lista curada de tags de alto valor accionable (cripto, fed, tech,
362
+ * geopolitica, energia, etc) y aplanamos los mercados de cada evento.
363
+ *
364
+ * @returns {Promise<Market[]>} Mercados unicos con categoria asignada por tag.
365
+ */
366
+ export async function fetchActiveMarkets() {
367
+ const results = await Promise.all(
368
+ TAG_SLICES.map((slice) => fetchEventsByTag(slice.tagId, slice.limit)),
369
+ );
370
+
371
+ // Dedup por id, preservando la categoria de la PRIMERA aparicion
372
+ // (los slices estan ordenados por prioridad de alpha financiero).
373
+ const seen = new Map(); // id β†’ { raw, eventTitle, tagCategory }
374
+ results.forEach((bucket, i) => {
375
+ const tagCategory = TAG_SLICES[i].category;
376
+ for (const { raw, eventTitle } of bucket) {
377
+ const id = String(raw.id);
378
+ if (seen.has(id)) continue;
379
+ seen.set(id, { raw, eventTitle, tagCategory });
380
+ }
381
+ });
382
+
383
+ const mapped = Array.from(seen.values()).map(({ raw, eventTitle, tagCategory }) =>
384
+ mapMarket(raw, { eventTitle, tagCategory }),
385
+ );
386
+
387
+ // Filtro de calidad: liquidez minima 5000 EUR para excluir orderbooks muertos
388
+ const filtered = mapped.filter((m) => (m.liquidityEur ?? 0) >= 5000 || (m.volumeEur ?? 0) >= 50000);
389
+
390
+ logger.info({
391
+ totalFetched: mapped.length,
392
+ afterLiquidityFilter: filtered.length,
393
+ perCategory: filtered.reduce((acc, m) => { acc[m.category] = (acc[m.category]||0)+1; return acc; }, {}),
394
+ }, 'polymarket diversified fetch complete');
395
+
396
+ return filtered;
397
  }
backend/src/middlewares/rateLimitLogin.js CHANGED
@@ -12,9 +12,11 @@
12
 
13
  import rateLimit from 'express-rate-limit';
14
 
 
 
15
  export const rateLimitLogin = rateLimit({
16
  windowMs: 15 * 60 * 1000,
17
- max: 5,
18
  standardHeaders: true,
19
  legacyHeaders: false,
20
  message: {
 
12
 
13
  import rateLimit from 'express-rate-limit';
14
 
15
+ const isProd = process.env.NODE_ENV === 'production';
16
+
17
  export const rateLimitLogin = rateLimit({
18
  windowMs: 15 * 60 * 1000,
19
+ max: isProd ? 5 : 50,
20
  standardHeaders: true,
21
  legacyHeaders: false,
22
  message: {
backend/src/positions/kelly.js CHANGED
@@ -12,9 +12,6 @@
12
  *
13
  * Devuelve:
14
  * Fraccion del capital virtual recomendada (0.0 a 0.25).
15
- *
16
- * Consumido por:
17
- * - positions.service.js β†’ al abrir una posicion.
18
  */
19
  export function kellyFraction(price, pWin) {
20
  if (!price || price <= 0 || price >= 1 || !pWin) return 0;
@@ -23,3 +20,76 @@ export function kellyFraction(price, pWin) {
23
  const k = (odds * pWin - pLose) / odds;
24
  return Math.min(Math.max(k, 0), 0.25);
25
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  *
13
  * Devuelve:
14
  * Fraccion del capital virtual recomendada (0.0 a 0.25).
 
 
 
15
  */
16
  export function kellyFraction(price, pWin) {
17
  if (!price || price <= 0 || price >= 1 || !pWin) return 0;
 
20
  const k = (odds * pWin - pLose) / odds;
21
  return Math.min(Math.max(k, 0), 0.25);
22
  }
23
+
24
+ const SPREAD_ILLIQUID_THRESHOLD = 0.05; // 5c bid/ask β†’ mercado ilΓ­quido
25
+ const KELLY_CONSERVATIVE_FACTOR = 0.25; // Quarter-Kelly por seguridad (LLM no calibrada)
26
+ const MAX_BANKROLL_FRACTION = 0.25; // No mas del 25% del bankroll por mercado
27
+
28
+ /**
29
+ * Calcula una sugerencia de tamano de posicion *aware* del spread bid/ask.
30
+ *
31
+ * Resta el coste de ejecucion (spread) al edge bruto que reporta la IA. Si el
32
+ * edge neto es <= 0, devuelve 0 (no recomienda apostar). Marca ilΓ­quidos los
33
+ * mercados con spread > 5c para que el frontend pueda deshabilitar el boton.
34
+ *
35
+ * @param {Object} args
36
+ * @param {number} args.yesPrice precio YES actual (0-1)
37
+ * @param {number} args.noPrice precio NO actual (0-1)
38
+ * @param {number} args.spread bid/ask spread (0-1) reportado por Polymarket
39
+ * @param {Object} args.signal { signal, confidence, edgePoints, fairProb }
40
+ * @param {number} [args.bankroll=1000] capital virtual base (€)
41
+ * @returns {{ outcome:'YES'|'NO'|null, fraction:number, amountEur:number,
42
+ * edgeNet:number, illiquid:boolean, note:string }}
43
+ */
44
+ export function suggestSize({ yesPrice, noPrice, spread = 0, signal, bankroll = 1000 }) {
45
+ const illiquid = (spread ?? 0) > SPREAD_ILLIQUID_THRESHOLD;
46
+
47
+ if (!signal || signal.edgePoints == null) {
48
+ return {
49
+ outcome: null,
50
+ fraction: 0,
51
+ amountEur: 0,
52
+ edgeNet: 0,
53
+ illiquid,
54
+ note: illiquid
55
+ ? `Mercado ilΓ­quido (spread ${Math.round((spread ?? 0) * 100)}Β’). Compra desaconsejada.`
56
+ : 'Sin seΓ±al IA disponible. No se puede calcular sugerencia.',
57
+ };
58
+ }
59
+
60
+ const rawEdge = Math.abs(signal.edgePoints) / 100; // 0-1 (probabilidad)
61
+ const netEdge = rawEdge - (spread ?? 0);
62
+ const outcome = signal.edgePoints > 0 ? 'YES' : 'NO';
63
+ const price = outcome === 'YES' ? yesPrice : noPrice;
64
+
65
+ if (netEdge <= 0.005 || !price || price <= 0 || price >= 1) {
66
+ return {
67
+ outcome: null,
68
+ fraction: 0,
69
+ amountEur: 0,
70
+ edgeNet: netEdge,
71
+ illiquid,
72
+ note: illiquid
73
+ ? `Mercado ilΓ­quido (spread ${Math.round((spread ?? 0) * 100)}Β’).`
74
+ : `Sin edge neto tras spread (${(rawEdge * 100).toFixed(1)}pp βˆ’ ${((spread ?? 0) * 100).toFixed(1)}pp). No apostar.`,
75
+ };
76
+ }
77
+
78
+ // Probabilidad efectiva = implied + edgeNet, capada en [0,1)
79
+ const pWin = Math.max(0, Math.min(0.99, price + netEdge));
80
+ // Quarter-Kelly por seguridad: la confianza del LLM no esta calibrada.
81
+ const k = kellyFraction(price, pWin) * KELLY_CONSERVATIVE_FACTOR;
82
+ const fraction = Math.min(MAX_BANKROLL_FRACTION, Math.max(0, k));
83
+ const amountEur = Math.round(bankroll * fraction);
84
+
85
+ return {
86
+ outcome,
87
+ fraction,
88
+ amountEur,
89
+ edgeNet: netEdge,
90
+ illiquid,
91
+ note: illiquid
92
+ ? `Mercado ilΓ­quido (spread ${Math.round((spread ?? 0) * 100)}Β’). Compra desaconsejada.`
93
+ : `Kelly conservador (ΒΌ) sobre edge neto ${(netEdge * 100).toFixed(1)}pp: €${amountEur} en ${outcome}.`,
94
+ };
95
+ }
backend/src/positions/positions.controller.js CHANGED
@@ -30,4 +30,10 @@ export const positionsController = {
30
  const position = await positionsService.close(req.params.id, req.user.id);
31
  ok(res, position);
32
  },
 
 
 
 
 
 
33
  };
 
30
  const position = await positionsService.close(req.params.id, req.user.id);
31
  ok(res, position);
32
  },
33
+
34
+ async suggest(req, res) {
35
+ const bankroll = req.query.bankroll ? parseFloat(req.query.bankroll) : 1000;
36
+ const suggestion = await positionsService.suggest(req.params.marketId, bankroll);
37
+ ok(res, suggestion);
38
+ },
39
  };
backend/src/positions/positions.routes.js CHANGED
@@ -17,6 +17,9 @@ import { openBody, idParam, listQuery } from './positions.validators.js';
17
 
18
  const router = Router();
19
 
 
 
 
20
  router.use(requireAuth);
21
 
22
  router.post('/', validate(openBody), positionsController.open);
 
17
 
18
  const router = Router();
19
 
20
+ // Endpoint publico (sugerencia de sizing) - no requiere autenticacion
21
+ router.get('/suggestion/:marketId', positionsController.suggest);
22
+
23
  router.use(requireAuth);
24
 
25
  router.post('/', validate(openBody), positionsController.open);
backend/src/positions/positions.service.js CHANGED
@@ -22,7 +22,7 @@ import { HttpError } from '../utils/apiResponse.js';
22
  import { positionsRepository } from './positions.repository.js';
23
  import { marketsRepository } from '../markets/markets.repository.js';
24
  import { signalsRepository } from '../signals/signals.repository.js';
25
- import { kellyFraction } from './kelly.js';
26
 
27
  function currentPriceForOutcome(market, outcome) {
28
  return outcome === 'YES' ? market.yesPrice : market.noPrice;
@@ -80,6 +80,29 @@ export const positionsService = {
80
  });
81
  },
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  async updateAllPnL() {
84
  const open = await positionsRepository.findAllOpen();
85
  await Promise.all(
 
22
  import { positionsRepository } from './positions.repository.js';
23
  import { marketsRepository } from '../markets/markets.repository.js';
24
  import { signalsRepository } from '../signals/signals.repository.js';
25
+ import { kellyFraction, suggestSize } from './kelly.js';
26
 
27
  function currentPriceForOutcome(market, outcome) {
28
  return outcome === 'YES' ? market.yesPrice : market.noPrice;
 
80
  });
81
  },
82
 
83
+ /**
84
+ * Sugiere outcome y tamano de posicion para un mercado, basado en:
85
+ * - Spread bid/ask (resta del edge)
86
+ * - Edge de la senal IA mas reciente (impliedProb vs fairProb)
87
+ * - Quarter-Kelly capado al 25% del bankroll
88
+ *
89
+ * Devuelve { outcome, fraction, amountEur, edgeNet, illiquid, note }.
90
+ */
91
+ async suggest(marketId, bankroll = 1000) {
92
+ const market = await marketsRepository.findById(marketId);
93
+ if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
94
+
95
+ const signal = await signalsRepository.findLatestByMarket(marketId);
96
+
97
+ return suggestSize({
98
+ yesPrice: market.yesPrice,
99
+ noPrice: market.noPrice,
100
+ spread: market.spread ?? 0,
101
+ signal,
102
+ bankroll,
103
+ });
104
+ },
105
+
106
  async updateAllPnL() {
107
  const open = await positionsRepository.findAllOpen();
108
  await Promise.all(
backend/src/scheduler.js CHANGED
@@ -22,12 +22,14 @@ import { logger } from './utils/logger.js';
22
 
23
  async function syncMarkets() {
24
  try {
25
- const markets = await fetchActiveMarkets(100);
26
  await Promise.all(markets.map((m) => marketsRepository.upsert(m)));
 
 
27
  for (const m of markets) {
28
  emitMarketUpdate({ marketId: m.id, yesPrice: m.yesPrice, noPrice: m.noPrice, volumeEur: m.volumeEur });
29
  }
30
- logger.info({ count: markets.length }, 'markets synced');
31
  } catch (err) {
32
  logger.error({ err: err.message }, 'syncMarkets failed');
33
  }
@@ -35,7 +37,14 @@ async function syncMarkets() {
35
 
36
  async function generateSignals() {
37
  try {
38
- const markets = await marketsRepository.findMany({ limit: 20, offset: 0, status: 'active' });
 
 
 
 
 
 
 
39
  for (const market of markets) {
40
  try {
41
  await signalsService.generateForMarket(market);
 
22
 
23
  async function syncMarkets() {
24
  try {
25
+ const markets = await fetchActiveMarkets();
26
  await Promise.all(markets.map((m) => marketsRepository.upsert(m)));
27
+ // Purga mercados activos que no aparecieron en este sync (restos de syncs previos)
28
+ const deactivated = await marketsRepository.deactivateStale(markets.map((m) => m.id));
29
  for (const m of markets) {
30
  emitMarketUpdate({ marketId: m.id, yesPrice: m.yesPrice, noPrice: m.noPrice, volumeEur: m.volumeEur });
31
  }
32
+ logger.info({ count: markets.length, deactivated }, 'markets synced');
33
  } catch (err) {
34
  logger.error({ err: err.message }, 'syncMarkets failed');
35
  }
 
37
 
38
  async function generateSignals() {
39
  try {
40
+ // Seleccion diversificada por categoria + liquidez (40 mercados/ciclo)
41
+ const markets = await marketsRepository.findDiversified(40);
42
+ const byCategory = markets.reduce((acc, m) => {
43
+ acc[m.category] = (acc[m.category] || 0) + 1;
44
+ return acc;
45
+ }, {});
46
+ logger.info({ total: markets.length, byCategory }, 'generating signals for diversified set');
47
+
48
  for (const market of markets) {
49
  try {
50
  await signalsService.generateForMarket(market);
backend/src/signals/aiPipeline.js CHANGED
@@ -21,13 +21,14 @@ import { Client } from '@gradio/client';
21
  import { httpPost } from '../utils/httpClient.js';
22
  import { config } from '../config.js';
23
  import { logger } from '../utils/logger.js';
24
- import { fetchFinancialNews, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
 
25
 
26
  const HF_API = 'https://api-inference.huggingface.co/models';
27
  const FINBERT_MODEL = 'ProsusAI/finbert';
28
  const QWEN_MODEL = 'Qwen/Qwen3-8B';
29
  const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
30
- const OPENROUTER_MODEL = 'deepseek/deepseek-chat-v3-0324:free';
31
 
32
  // Clientes Gradio en cache para Spaces
33
  let modernFinBERTClient = null;
@@ -51,19 +52,58 @@ function extractJson(text) {
51
  return null;
52
  }
53
 
54
- function buildPrompt(market, headlines) {
55
  const newsSection = headlines.length
56
- ? `Relevant news:\n${headlines.map((h) => `- ${h.headline}`).join('\n')}`
57
- : 'No relevant news available.';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  return [
59
- `You are a prediction market analyst. Analyze the following market and respond ONLY with valid JSON.`,
60
  ``,
61
  `Market: "${market.question}"`,
62
- `YES price: ${market.yesPrice ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
 
 
 
 
 
63
  newsSection,
64
  ``,
65
- `Return ONLY this JSON structure, no markdown, no explanation:`,
66
- `{"signal":"bullish"|"bearish"|"neutral","confidence":<0.0-1.0>,"summary":"<2 sentences>","keyRisk":"<1 sentence>"}`,
 
 
 
 
 
 
 
 
67
  ].join('\n');
68
  }
69
 
@@ -71,14 +111,18 @@ function buildPrompt(market, headlines) {
71
 
72
  async function getModernFinBERTClient() {
73
  if (!modernFinBERTClient && config.HF_SPACE_MODERNFINBERT_URL) {
74
- modernFinBERTClient = await Client.connect(config.HF_SPACE_MODERNFINBERT_URL);
 
 
75
  }
76
  return modernFinBERTClient;
77
  }
78
 
79
  async function getQwenClient() {
80
  if (!qwenClient && config.HF_SPACE_QWEN_URL) {
81
- qwenClient = await Client.connect(config.HF_SPACE_QWEN_URL);
 
 
82
  }
83
  return qwenClient;
84
  }
@@ -134,7 +178,7 @@ async function filterWithFinBERT(articles) {
134
 
135
  // ── LLM generation via Space or direct API ───────────────────────────────────
136
 
137
- async function generateWithQwenSpace(market, headlines) {
138
  const client = await getQwenClient();
139
  if (!client) return null;
140
 
@@ -142,9 +186,13 @@ async function generateWithQwenSpace(market, headlines) {
142
  ? headlines.map((h) => `- ${h.headline}`).join('\n')
143
  : 'No relevant news available.';
144
 
 
 
 
 
145
  const marketContext = [
146
  `Market: "${market.question}"`,
147
- `YES price: ${market.yesPrice ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
148
  ].join('\n');
149
 
150
  const result = await client.predict('/predict', {
@@ -153,7 +201,9 @@ async function generateWithQwenSpace(market, headlines) {
153
  });
154
 
155
  // result.data[0] β†’ { signal, confidence, summary, keyRisk }
156
- return result.data[0];
 
 
157
  }
158
 
159
  async function callChatCompletion(url, model, messages, authHeader) {
@@ -165,24 +215,28 @@ async function callChatCompletion(url, model, messages, authHeader) {
165
  return data?.choices?.[0]?.message?.content ?? null;
166
  }
167
 
168
- async function generateWithQwen3Direct(market, headlines) {
169
  const content = await callChatCompletion(
170
  `${HF_API}/${QWEN_MODEL}/v1/chat/completions`,
171
  QWEN_MODEL,
172
- [{ role: 'user', content: buildPrompt(market, headlines) }],
173
  `Bearer ${config.HF_TOKEN}`,
174
  );
175
- return content ? extractJson(content) : null;
 
 
176
  }
177
 
178
- async function generateWithOpenRouter(market, headlines) {
179
  const content = await callChatCompletion(
180
  OPENROUTER_API,
181
  OPENROUTER_MODEL,
182
- [{ role: 'user', content: buildPrompt(market, headlines) }],
183
  `Bearer ${config.OPENROUTER_API_KEY}`,
184
  );
185
- return content ? extractJson(content) : null;
 
 
186
  }
187
 
188
  // ── rule-based fallback ───────────────────────────────────────────────────────
@@ -195,6 +249,7 @@ function ruleBasedSignal(market) {
195
  confidence: Math.min(p, 0.9),
196
  summary: `Market strongly favors YES at ${(p * 100).toFixed(0)}% probability. Momentum is positive.`,
197
  keyRisk: 'Sentiment shift could rapidly reprice the market.',
 
198
  };
199
  if (p <= 0.35)
200
  return {
@@ -202,12 +257,14 @@ function ruleBasedSignal(market) {
202
  confidence: Math.min(1 - p, 0.9),
203
  summary: `Market strongly favors NO at ${((1 - p) * 100).toFixed(0)}% probability. Downside momentum dominates.`,
204
  keyRisk: 'Unexpected positive developments could reverse this rapidly.',
 
205
  };
206
  return {
207
  signal: 'neutral',
208
  confidence: 0.5,
209
  summary: 'Market is closely contested with no clear directional signal. Both outcomes remain plausible.',
210
  keyRisk: 'High uncertainty in both directions; position sizing should be conservative.',
 
211
  };
212
  }
213
 
@@ -236,12 +293,25 @@ function normalizeSignal(result) {
236
 
237
  // ── public API ────────────────────────────────────────────────────────────────
238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  export async function run(market) {
240
- // Paso 1: obtiene y filtra noticias
241
  let headlines = [];
242
  try {
243
- const allNews = await fetchFinancialNews(30);
244
- const relevant = filterNewsByRelevance(allNews, market.question);
245
  if (relevant.length) {
246
  headlines = await filterWithFinBERT(relevant);
247
  } else {
@@ -251,13 +321,24 @@ export async function run(market) {
251
  logger.warn({ err: err.message, marketId: market.id }, 'news fetch failed, continuing without news');
252
  }
253
 
 
 
 
 
 
 
 
 
 
 
 
254
  // Paso 2: generacion de senal LLM con cadena de respaldo
255
  let result = null;
256
 
257
  // Intenta Space primero
258
  if (config.HF_SPACE_QWEN_URL) {
259
  try {
260
- result = await generateWithQwenSpace(market, headlines);
261
  result = normalizeSignal(result);
262
  if (!validateSignal(result)) result = null;
263
  } catch (err) {
@@ -268,7 +349,7 @@ export async function run(market) {
268
  // Respaldo a API directa de HF
269
  if (!result && config.HF_TOKEN) {
270
  try {
271
- result = await generateWithQwen3Direct(market, headlines);
272
  if (!validateSignal(result)) result = null;
273
  } catch (err) {
274
  logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 direct API failed, trying OpenRouter');
@@ -278,10 +359,10 @@ export async function run(market) {
278
  // Respaldo a OpenRouter
279
  if (!result && config.OPENROUTER_API_KEY) {
280
  try {
281
- result = await generateWithOpenRouter(market, headlines);
282
  if (!validateSignal(result)) result = null;
283
  } catch (err) {
284
- logger.warn({ err: err.message, marketId: market.id }, 'OpenRouter failed, using rule-based');
285
  }
286
  }
287
 
@@ -290,5 +371,16 @@ export async function run(market) {
290
  result = ruleBasedSignal(market);
291
  }
292
 
293
- return { ...result, newsCount: headlines.length };
 
 
 
 
 
 
 
 
 
 
 
294
  }
 
21
  import { httpPost } from '../utils/httpClient.js';
22
  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;
 
52
  return null;
53
  }
54
 
55
+ function buildPrompt(market, headlines, cryptoContext = null) {
56
  const newsSection = headlines.length
57
+ ? `Recent news (use these to detect mispricing):\n${headlines.map((h) => `- ${h.headline}`).join('\n')}`
58
+ : 'No news available. Reason from base rates, the current implied probability, and the time-to-resolution.';
59
+
60
+ const yes = market.yesPrice;
61
+ const priceContext = yes != null
62
+ ? `Implied YES probability: ${(yes * 100).toFixed(1)}%. The market is pricing this outcome at ${yes >= 0.7 ? 'HIGH confidence YES' : yes <= 0.3 ? 'HIGH confidence NO' : 'a contested midrange'}.`
63
+ : 'No price available.';
64
+
65
+ // Ground truth de cripto cuando aplica
66
+ const cryptoSection = cryptoContext
67
+ ? [
68
+ ``,
69
+ `GROUND TRUTH (verified spot data):`,
70
+ `${cryptoContext.symbol} current spot price: $${cryptoContext.currentPrice.toLocaleString()}`,
71
+ `Target in question: $${cryptoContext.targetPrice.toLocaleString()}`,
72
+ `Required move from spot: ${cryptoContext.requiredMovePct >= 0 ? '+' : ''}${cryptoContext.requiredMovePct.toFixed(1)}%`,
73
+ `Use this to judge whether the implied probability is plausible given typical volatility.`,
74
+ ].join('\n')
75
+ : '';
76
+
77
+ // Tiempo hasta resolucion
78
+ const daysToClose = market.closesAt
79
+ ? Math.max(0, Math.ceil((new Date(market.closesAt) - Date.now()) / (1000 * 60 * 60 * 24)))
80
+ : null;
81
+ const timeSection = daysToClose != null
82
+ ? `Days until resolution: ${daysToClose}.`
83
+ : '';
84
+
85
  return [
86
+ `You are a sharp prediction-market trader looking for EDGE β€” situations where the market price diverges from the fair probability given recent information.`,
87
  ``,
88
  `Market: "${market.question}"`,
89
+ `Category: ${market.category ?? 'general'}`,
90
+ `YES price: ${yes ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
91
+ priceContext,
92
+ timeSection,
93
+ cryptoSection,
94
+ ``,
95
  newsSection,
96
  ``,
97
+ `Your job is NOT to repeat what the market price says. Your job is to identify if the market is MISPRICED:`,
98
+ `- "bullish" = you think YES is undervalued (true probability > implied price). Higher confidence when news supports YES but price hasn't moved yet.`,
99
+ `- "bearish" = you think NO is undervalued (true probability < implied price). Higher confidence when news supports NO but price hasn't reacted.`,
100
+ `- "neutral" = market price looks fair given available information; no edge.`,
101
+ ``,
102
+ `Confidence calibration: 0.5-0.6 = weak lean, 0.6-0.75 = clear edge, 0.75-0.9 = strong conviction with concrete catalyst.`,
103
+ `Summary must mention WHY (specific news item or price-vs-fundamentals gap). Avoid generic statements.`,
104
+ ``,
105
+ `Return ONLY this JSON, no markdown, no explanation:`,
106
+ `{"signal":"bullish"|"bearish"|"neutral","confidence":<0.0-1.0>,"summary":"<2 sentences explaining the EDGE>","keyRisk":"<1 sentence: what would invalidate this thesis>"}`,
107
  ].join('\n');
108
  }
109
 
 
111
 
112
  async function getModernFinBERTClient() {
113
  if (!modernFinBERTClient && config.HF_SPACE_MODERNFINBERT_URL) {
114
+ modernFinBERTClient = await Client.connect(config.HF_SPACE_MODERNFINBERT_URL, {
115
+ token: config.HF_TOKEN,
116
+ });
117
  }
118
  return modernFinBERTClient;
119
  }
120
 
121
  async function getQwenClient() {
122
  if (!qwenClient && config.HF_SPACE_QWEN_URL) {
123
+ qwenClient = await Client.connect(config.HF_SPACE_QWEN_URL, {
124
+ token: config.HF_TOKEN,
125
+ });
126
  }
127
  return qwenClient;
128
  }
 
178
 
179
  // ── LLM generation via Space or direct API ───────────────────────────────────
180
 
181
+ async function generateWithQwenSpace(market, headlines, cryptoContext = null) {
182
  const client = await getQwenClient();
183
  if (!client) return null;
184
 
 
186
  ? headlines.map((h) => `- ${h.headline}`).join('\n')
187
  : 'No relevant news available.';
188
 
189
+ const cryptoLine = cryptoContext
190
+ ? `\nGround truth: ${cryptoContext.symbol} spot $${cryptoContext.currentPrice.toLocaleString()}, target $${cryptoContext.targetPrice.toLocaleString()} (${cryptoContext.requiredMovePct >= 0 ? '+' : ''}${cryptoContext.requiredMovePct.toFixed(1)}% required).`
191
+ : '';
192
+
193
  const marketContext = [
194
  `Market: "${market.question}"`,
195
+ `YES price: ${market.yesPrice ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}${cryptoLine}`,
196
  ].join('\n');
197
 
198
  const result = await client.predict('/predict', {
 
201
  });
202
 
203
  // result.data[0] β†’ { signal, confidence, summary, keyRisk }
204
+ const data = result.data[0];
205
+ if (data) data.modelVersion = 'HF Space Qwen3-8B';
206
+ return data;
207
  }
208
 
209
  async function callChatCompletion(url, model, messages, authHeader) {
 
215
  return data?.choices?.[0]?.message?.content ?? null;
216
  }
217
 
218
+ async function generateWithQwen3Direct(market, headlines, cryptoContext = null) {
219
  const content = await callChatCompletion(
220
  `${HF_API}/${QWEN_MODEL}/v1/chat/completions`,
221
  QWEN_MODEL,
222
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
223
  `Bearer ${config.HF_TOKEN}`,
224
  );
225
+ const data = content ? extractJson(content) : null;
226
+ if (data) data.modelVersion = 'HF API Qwen3-8B';
227
+ return data;
228
  }
229
 
230
+ async function generateWithOpenRouter(market, headlines, cryptoContext = null) {
231
  const content = await callChatCompletion(
232
  OPENROUTER_API,
233
  OPENROUTER_MODEL,
234
+ [{ role: 'user', content: buildPrompt(market, headlines, cryptoContext) }],
235
  `Bearer ${config.OPENROUTER_API_KEY}`,
236
  );
237
+ const data = content ? extractJson(content) : null;
238
+ if (data) data.modelVersion = 'OpenRouter DeepSeek-V3';
239
+ return data;
240
  }
241
 
242
  // ── rule-based fallback ───────────────────────────────────────────────────────
 
249
  confidence: Math.min(p, 0.9),
250
  summary: `Market strongly favors YES at ${(p * 100).toFixed(0)}% probability. Momentum is positive.`,
251
  keyRisk: 'Sentiment shift could rapidly reprice the market.',
252
+ modelVersion: 'Rule-based (price-only)',
253
  };
254
  if (p <= 0.35)
255
  return {
 
257
  confidence: Math.min(1 - p, 0.9),
258
  summary: `Market strongly favors NO at ${((1 - p) * 100).toFixed(0)}% probability. Downside momentum dominates.`,
259
  keyRisk: 'Unexpected positive developments could reverse this rapidly.',
260
+ modelVersion: 'Rule-based (price-only)',
261
  };
262
  return {
263
  signal: 'neutral',
264
  confidence: 0.5,
265
  summary: 'Market is closely contested with no clear directional signal. Both outcomes remain plausible.',
266
  keyRisk: 'High uncertainty in both directions; position sizing should be conservative.',
267
+ modelVersion: 'Rule-based (price-only)',
268
  };
269
  }
270
 
 
293
 
294
  // ── public API ────────────────────────────────────────────────────────────────
295
 
296
+ /**
297
+ * Mapea (signal, confidence) β†’ probabilidad "justa" YES segun la IA.
298
+ * bullish + conf 0.8 β†’ 0.5 + 0.8 * 0.5 = 0.9 (YES muy probable)
299
+ * bearish + conf 0.8 β†’ 0.5 - 0.8 * 0.5 = 0.1 (YES improbable)
300
+ * neutral β†’ 0.5
301
+ */
302
+ function signalToFairProbability(signal, confidence) {
303
+ const c = Math.max(0, Math.min(1, confidence ?? 0.5));
304
+ if (signal === 'bullish') return 0.5 + c * 0.5;
305
+ if (signal === 'bearish') return 0.5 - c * 0.5;
306
+ return 0.5;
307
+ }
308
+
309
  export async function run(market) {
310
+ // Paso 1: obtiene noticias financieras RELEVANTES para este mercado especifico
311
  let headlines = [];
312
  try {
313
+ const marketNews = await fetchFinancialNewsForMarket(market);
314
+ const relevant = filterNewsByRelevance(marketNews, market.question);
315
  if (relevant.length) {
316
  headlines = await filterWithFinBERT(relevant);
317
  } else {
 
321
  logger.warn({ err: err.message, marketId: market.id }, 'news fetch failed, continuing without news');
322
  }
323
 
324
+ // Paso 1b: ground truth de cripto si aplica (BTC/ETH/SOL spot vs target)
325
+ let cryptoContext = null;
326
+ try {
327
+ cryptoContext = await analyzeCryptoTarget(market.question);
328
+ if (cryptoContext) {
329
+ logger.debug({ marketId: market.id, ...cryptoContext }, 'crypto ground truth injected');
330
+ }
331
+ } catch (err) {
332
+ logger.warn({ err: err.message }, 'crypto context fetch failed');
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);
343
  if (!validateSignal(result)) result = null;
344
  } catch (err) {
 
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');
 
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
 
 
371
  result = ruleBasedSignal(market);
372
  }
373
 
374
+ // Calcula edge: (fairProb - impliedProb)
375
+ const impliedProb = market.yesPrice ?? null;
376
+ const fairProb = signalToFairProbability(result.signal, result.confidence);
377
+ const edgePoints = impliedProb != null ? (fairProb - impliedProb) * 100 : null;
378
+
379
+ return {
380
+ ...result,
381
+ newsCount: headlines.length,
382
+ impliedProb,
383
+ fairProb,
384
+ edgePoints,
385
+ };
386
  }
backend/src/signals/signals.repository.js CHANGED
@@ -14,9 +14,9 @@
14
  import { prisma } from '../utils/prisma.js';
15
 
16
  export const signalsRepository = {
17
- create({ marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion }) {
18
  return prisma.aISignal.create({
19
- data: { marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion },
20
  });
21
  },
22
 
 
14
  import { prisma } from '../utils/prisma.js';
15
 
16
  export const signalsRepository = {
17
+ create({ marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion, impliedProb, fairProb, edgePoints }) {
18
  return prisma.aISignal.create({
19
+ data: { marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion, impliedProb, fairProb, edgePoints },
20
  });
21
  },
22
 
backend/src/signals/signals.service.js CHANGED
@@ -39,10 +39,17 @@ export const signalsService = {
39
  },
40
 
41
  async generateForMarket(market) {
42
- const modelVersion = 'Qwen3-8B';
 
 
 
 
 
 
43
  const result = await aiPipeline.run(market);
 
44
  const saved = await signalsRepository.create({ marketId: market.id, modelVersion, ...result });
45
- emitAiSignal({ marketId: market.id, signal: saved.signal, confidence: saved.confidence, summary: saved.summary });
46
  logger.info({ marketId: market.id, signal: saved.signal }, 'signal generated');
47
  return saved;
48
  },
 
39
  },
40
 
41
  async generateForMarket(market) {
42
+ // Saltar mercados no analizables (sports, predicciones de palabras, memes).
43
+ // Mejor "sin senal" que confianza falsa.
44
+ if (market.analyzable === false) {
45
+ logger.debug({ marketId: market.id, question: market.question }, 'skipping non-analyzable market');
46
+ return null;
47
+ }
48
+
49
  const result = await aiPipeline.run(market);
50
+ const modelVersion = result.modelVersion || 'Unknown';
51
  const saved = await signalsRepository.create({ marketId: market.id, modelVersion, ...result });
52
+ emitAiSignal({ marketId: market.id, signal: saved.signal, confidence: saved.confidence, summary: saved.summary, modelVersion: saved.modelVersion });
53
  logger.info({ marketId: market.id, signal: saved.signal }, 'signal generated');
54
  return saved;
55
  },
backend/src/utils/coingecko.client.js ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Cliente CoinGecko para spot prices de cripto.
3
+ *
4
+ * Sin API key (free tier publico). Rate limit ~30 req/min β€” usamos cache TTL 60s
5
+ * para evitar excederlo cuando hay muchos mercados de cripto en un ciclo.
6
+ *
7
+ * Consumido por:
8
+ * - signals/aiPipeline.js β†’ enriquece el prompt con ground truth para
9
+ * mercados de precio objetivo (BTC $150k, ETH $5k, etc).
10
+ */
11
+
12
+ import { httpGet } from './httpClient.js';
13
+ import { logger } from './logger.js';
14
+
15
+ const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
16
+ const CACHE_TTL_MS = 60_000;
17
+
18
+ // Mapeo symbol β†’ id de CoinGecko
19
+ const COIN_IDS = {
20
+ BTC: 'bitcoin',
21
+ ETH: 'ethereum',
22
+ SOL: 'solana',
23
+ DOGE: 'dogecoin',
24
+ XRP: 'ripple',
25
+ ADA: 'cardano',
26
+ AVAX: 'avalanche-2',
27
+ LINK: 'chainlink',
28
+ MATIC: 'matic-network',
29
+ DOT: 'polkadot',
30
+ };
31
+
32
+ let cache = { data: null, timestamp: 0 };
33
+
34
+ async function refreshCache() {
35
+ const ids = Object.values(COIN_IDS).join(',');
36
+ const url = `${COINGECKO_URL}?ids=${ids}&vs_currencies=usd`;
37
+ try {
38
+ const data = await httpGet(url, { timeout: 8000, retries: 1 });
39
+ // Normaliza: { bitcoin: { usd: 103400 }, ... } β†’ { BTC: 103400, ... }
40
+ const normalized = {};
41
+ for (const [symbol, id] of Object.entries(COIN_IDS)) {
42
+ if (data[id]?.usd != null) normalized[symbol] = data[id].usd;
43
+ }
44
+ cache = { data: normalized, timestamp: Date.now() };
45
+ return normalized;
46
+ } catch (err) {
47
+ logger.warn({ err: err.message }, 'CoinGecko fetch failed');
48
+ return cache.data || {};
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Devuelve { BTC: 103400, ETH: 3450, SOL: 142, ... } en USD.
54
+ * Cache TTL 60s.
55
+ */
56
+ export async function getSpotPrices() {
57
+ if (cache.data && Date.now() - cache.timestamp < CACHE_TTL_MS) {
58
+ return cache.data;
59
+ }
60
+ return refreshCache();
61
+ }
62
+
63
+ /**
64
+ * Detecta si una pregunta de mercado es de "precio objetivo cripto".
65
+ * Devuelve { symbol, targetPrice, currentPrice, requiredMovePct, deadline } o null.
66
+ */
67
+ export async function analyzeCryptoTarget(question) {
68
+ if (!question) return null;
69
+
70
+ // Detecta symbol (BTC|Bitcoin|ETH|Ethereum|SOL|Solana)
71
+ const symbolMap = [
72
+ { rx: /\b(bitcoin|btc)\b/i, sym: 'BTC' },
73
+ { rx: /\b(ethereum|eth)\b/i, sym: 'ETH' },
74
+ { rx: /\b(solana|sol)\b/i, sym: 'SOL' },
75
+ { rx: /\b(dogecoin|doge)\b/i, sym: 'DOGE' },
76
+ { rx: /\b(xrp|ripple)\b/i, sym: 'XRP' },
77
+ { rx: /\b(cardano|ada)\b/i, sym: 'ADA' },
78
+ ];
79
+ const match = symbolMap.find(({ rx }) => rx.test(question));
80
+ if (!match) return null;
81
+
82
+ // Detecta target price ($150k, $3,500, $100,000)
83
+ const priceMatch = question.match(/\$\s?([\d,]+(?:\.\d+)?)\s?([kKmM])?/);
84
+ if (!priceMatch) return null;
85
+ let target = parseFloat(priceMatch[1].replace(/,/g, ''));
86
+ if (priceMatch[2]?.toLowerCase() === 'k') target *= 1000;
87
+ if (priceMatch[2]?.toLowerCase() === 'm') target *= 1_000_000;
88
+
89
+ const spots = await getSpotPrices();
90
+ const current = spots[match.sym];
91
+ if (!current || !target) return null;
92
+
93
+ const requiredMovePct = ((target - current) / current) * 100;
94
+ return {
95
+ symbol: match.sym,
96
+ currentPrice: current,
97
+ targetPrice: target,
98
+ requiredMovePct,
99
+ };
100
+ }
frontend/dist/assets/index-B7Y-b3uS.js DELETED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-D_WoXxRV.css ADDED
@@ -0,0 +1 @@
 
 
1
+ @import "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;500;600;700&display=swap";:root{--bg:#0a0c10;--bg2:#111318;--bg3:#181c24;--bg4:#1e232d;--border:#ffffff14;--border2:#ffffff21;--text:#e8eaf0;--text2:#8b90a0;--text3:#7e8494;--green:#22d37a;--green2:#0d6e3a;--green3:#052a17;--red:#f04040;--red2:#7a1a1a;--red3:#2d0808;--blue:#4a9eff;--blue2:#1a4a80;--blue3:#081830;--amber:#f0a020;--amber2:#7a4e08;--amber3:#2d1c02;--accent:#4a9eff;--font-sans:"Syne", sans-serif;--font-mono:"DM Mono", monospace;--sidebar-width:240px;--sidebar-collapsed:56px;--topbar-height:56px;--panel-gap:16px;--radius:10px;--radius-sm:6px;--fs-p:clamp(1rem, 1.15vw, 1.125rem);--fs-h1:clamp(1.75rem, 2.8vw, 2.25rem);--fs-h2:clamp(1.5rem, 2.4vw, 1.875rem);--fs-h3:clamp(1.25rem, 1.9vw, 1.5rem);--fs-h4:clamp(1.125rem, 1.6vw, 1.25rem);--fs-h5:clamp(1rem, 1.3vw, 1.125rem);--fs-h6:clamp(.875rem, 1.1vw, 1rem)}*{box-sizing:border-box;margin:0;padding:0}html{font-size:16px}html,body,#app{width:100%;height:100%;overflow:hidden}body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;font-size:1rem;line-height:1.5}p{font-size:var(--fs-p);line-height:1.55}h1{font-size:var(--fs-h1);font-weight:700;line-height:1.15}h2{font-size:var(--fs-h2);font-weight:700;line-height:1.2}h3{font-size:var(--fs-h3);font-weight:600;line-height:1.25}h4{font-size:var(--fs-h4);font-weight:600;line-height:1.3}h5{font-size:var(--fs-h5);font-weight:500;line-height:1.35}h6{font-size:var(--fs-h6);font-weight:500;line-height:1.4}.layout{grid-template-areas:"topbar topbar""sidebar main";grid-template-columns:var(--sidebar-width) 1fr;grid-template-rows:var(--topbar-height) 1fr;width:100vw;height:100vh;transition:grid-template-columns .25s;display:grid}.layout.collapsed{grid-template-columns:var(--sidebar-collapsed) 1fr}.sidebar{background:var(--bg2);border-right:1px solid var(--border);flex-direction:column;grid-area:sidebar;transition:width .25s;display:flex;position:relative;overflow:hidden}.sidebar-toggle{background:var(--bg3);border:1px solid var(--border2);width:24px;height:24px;color:var(--text2);cursor:pointer;z-index:10;border-radius:50%;justify-content:center;align-items:center;font-size:.875rem;transition:transform .2s;display:flex;position:absolute;top:12px;right:-10px}.layout.collapsed .sidebar-toggle{right:-10px;transform:rotate(180deg)}.topbar-logo{height:var(--topbar-height);z-index:100;align-items:center;gap:10px;display:flex;position:fixed;top:0;left:16px}.topbar-logo .logo-dot{background:var(--blue);border-radius:50%;flex-shrink:0;width:10px;height:10px;animation:2s ease-in-out infinite pulse}.topbar-logo .logo-text{color:var(--text);letter-spacing:-.3px;white-space:nowrap;font-size:1.125rem;font-weight:700}.sidebar-nav{flex-direction:column;flex:1;gap:4px;padding:14px 8px;display:flex;overflow-y:auto}.nav-item{border-radius:var(--radius-sm);cursor:pointer;color:var(--text2);white-space:nowrap;align-items:center;gap:14px;padding:14px;font-size:1rem;font-weight:500;transition:background .15s,color .15s;display:flex;overflow:hidden}.nav-item:hover{background:var(--bg3);color:var(--text)}.nav-item.active{background:var(--blue3);color:var(--blue);border:.5px solid var(--blue2)}.nav-icon{text-align:center;flex-shrink:0;width:20px;font-size:1rem}.nav-label{opacity:1;transition:opacity .15s}.layout.collapsed .nav-label{opacity:0;width:0}.sidebar-footer{border-top:1px solid var(--border);color:var(--text3);font-size:.875rem;font-family:var(--font-mono);text-align:center;white-space:nowrap;padding:14px;overflow:hidden}.layout.collapsed .sidebar-footer{opacity:0}.topbar{background:var(--bg2);border-bottom:1px solid var(--border);padding:0 16px;padding-left:calc(var(--sidebar-width) + var(--panel-gap));grid-area:topbar;align-items:center;gap:16px;display:flex;position:relative;overflow:hidden}.layout.collapsed .topbar{padding-left:calc(var(--sidebar-width) + var(--panel-gap))}.live-badge{font-family:var(--font-mono);background:var(--green3);color:var(--green);border:.5px solid var(--green2);border-radius:4px;flex-shrink:0;align-items:center;gap:4px;padding:6px 12px;font-size:.875rem;display:flex}.live-dot{background:var(--green);border-radius:50%;width:8px;height:8px;animation:1.5s ease-in-out infinite pulse}.topbar-stats{flex:1;align-items:center;gap:20px;display:flex;overflow:hidden}.stat{flex-shrink:0;align-items:center;gap:14px;display:flex}.stat-label{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em}.stat-val{color:var(--text);font-size:1rem;font-weight:600;font-family:var(--font-mono)}.stat-delta{font-size:.875rem;font-family:var(--font-mono)}.stat-delta.up{color:var(--green)}.stat-delta.dn{color:var(--red)}.stat-delta.neutral{color:var(--text3)}.topbar-filters{flex-shrink:0;align-items:center;gap:10px;display:flex}.filter-select{background:var(--bg3);border:.5px solid var(--border2);border-radius:var(--radius-sm);color:var(--text);font-size:.875rem;font-family:var(--font-mono);cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b90a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");background-position:right 10px center;background-repeat:no-repeat;outline:none;min-width:140px;max-width:180px;padding:7px 30px 7px 12px;transition:border-color .15s}.filter-select:hover{border-color:var(--border2)}.filter-select:focus{border-color:var(--blue2)}.filter-select option{background:var(--bg2);color:var(--text)}.topbar-actions{flex-shrink:0;align-items:center;gap:16px;margin-left:auto;display:flex}.btn-ghost{color:var(--blue);border:.5px solid var(--blue2);background:var(--blue3);border-radius:var(--radius-sm);cursor:pointer;font-size:.9375rem;font-weight:500;font-family:var(--font-sans);padding:8px 16px;transition:opacity .15s}.btn-ghost:hover{opacity:.85}.icon-btn{background:var(--bg3);border:.5px solid var(--border2);width:32px;height:32px;color:var(--text2);cursor:pointer;border-radius:50%;justify-content:center;align-items:center;font-size:1rem;transition:color .15s,border-color .15s;display:flex}.icon-btn:hover{color:var(--text);border-color:var(--border2)}.main{padding:var(--panel-gap);background:var(--bg);grid-area:main;overflow:auto}.view{display:none}.view.active{height:100%;display:block}.dashboard-grid{gap:var(--panel-gap);grid-template-rows:minmax(120px,59%) 1fr;grid-template-columns:1fr 336px;height:100%;min-height:0;display:grid}.panel{background:var(--bg2);border:.5px solid var(--border);border-radius:var(--radius);flex-direction:column;min-height:0;display:flex;overflow:hidden}.panel-header{border-bottom:.5px solid var(--border);cursor:pointer;-webkit-user-select:none;user-select:none;justify-content:space-between;align-items:center;padding:14px;transition:background .15s;display:flex}.panel-header:hover{background:#ffffff05}.panel-title{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;align-items:center;gap:14px;display:flex}.panel-toggle{color:var(--text3);font-size:.875rem;transition:transform .2s}.panel.collapsed .panel-toggle{transform:rotate(-90deg)}.panel-body{flex:1;min-height:0;padding:12px;overflow:auto}.panel.collapsed .panel-body{display:none}.panel-subtitle{color:var(--text3);font-size:.875rem}.positions-separator{border-top:.5px solid var(--border);margin-top:10px;padding-top:10px}.panel-title.mb-sm{margin-bottom:8px}.panel.full-height{height:100%}.hidden{display:none}.desktop-only{display:initial}.mobile-only,#btn-notif,#btn-auth-mobile,.topbar-stats .legend{display:none}.map-legend{align-items:center;gap:12px;margin-left:auto;display:flex}#panel-map .panel-header{justify-content:flex-start}.map-panel{grid-area:1/1/2/2}#map-container{background:var(--bg3);border-radius:var(--radius-sm);width:100%;height:100%;min-height:300px;overflow:hidden}.signals-panel{grid-area:1/2/3/3}.signals-list{flex-direction:column;gap:16px;display:flex}.market-card{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;padding:14px;transition:border-color .15s,background .15s}.market-card:hover{border-color:var(--border2)}.market-card.active{border-color:var(--blue2);background:var(--blue3)}.market-cat{font-size:.875rem;font-family:var(--font-mono);color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}.market-q{color:var(--text);margin-bottom:8px;font-size:.9375rem;font-weight:500;line-height:1.4}.market-footer{justify-content:space-between;align-items:center;gap:16px;display:flex}.prob-bar-wrap{flex:1;min-width:0}.prob-bar-bg{background:var(--bg4);border-radius:2px;height:5px;overflow:hidden}.prob-bar-fill{height:100%;width:var(--prob-width,0%);border-radius:2px;transition:width .8s}.prob-val{font-size:.9375rem;font-weight:600;font-family:var(--font-mono);flex-shrink:0}.signal-badge{font-size:.875rem;font-weight:600;font-family:var(--font-mono);letter-spacing:.04em;border-radius:4px;flex-shrink:0;padding:2px 6px}.sig-bull{background:var(--green3);color:var(--green);border:.5px solid var(--green2)}.sig-bear{background:var(--red3);color:var(--red);border:.5px solid var(--red2)}.sig-neut{background:var(--bg4);color:var(--text2);border:.5px solid var(--border2)}.sig-none{color:var(--text3,#6e7681);border:.5px dashed var(--border2);letter-spacing:.06em;background:0 0;font-size:.72rem;font-weight:500}.spread-badge{color:var(--text3,#6e7681);font-size:.7rem;font-family:var(--font-mono);margin-left:6px}.spread-badge.illiquid{color:var(--red,#f04040);font-weight:600}.edge-row{border-top:.5px solid var(--border2,#1f242b);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.02em;align-items:center;gap:6px;margin-top:6px;padding-top:6px;display:flex}.edge-implied{color:var(--text2,#9aa4b2)}.edge-fair{font-weight:600}.edge-sep{color:var(--text3,#4a5260);opacity:.6}.edge-value{border-radius:3px;padding:1px 5px;font-weight:700}.edge-pos{color:var(--green,#22d37a);background:var(--green3,#22d37a14)}.edge-neg{color:var(--red,#f04040);background:var(--red3,#f0404014)}.edge-zero{color:var(--text3,#6e7681)}.kelly-note{color:var(--text2,#9aa4b2);font-size:.78rem;font-family:var(--font-mono);border-left:2px solid var(--border2,#1f242b);background:#ffffff05;border-radius:0 4px 4px 0;flex:1;padding:4px 8px}.kelly-warn{color:var(--red,#f04040);border-left-color:var(--red,#f04040);background:#f040400d}.detail-panel{grid-area:2/1/3/2;overflow:hidden}.detail-panel .panel-body{overflow:hidden}.detail-panel .panel-body>div{flex-direction:column;gap:10px;height:100%;min-height:0;display:flex}.detail-header{justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:12px;display:flex}.detail-tag{color:var(--blue);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.detail-q{color:var(--text);font-size:1rem;font-weight:600;line-height:1.5}.detail-meta{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);margin-top:2px}.detail-metrics{flex-shrink:0;align-items:center;gap:16px;display:flex}.metric{text-align:right}.metric-label{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);margin-bottom:2px}.metric-value{font-size:.875rem;font-weight:700;font-family:var(--font-mono)}.metric-sep{background:var(--border);width:1px;height:40px}.outcomes-row{flex:1;gap:16px;min-height:0;display:flex}.outcome-card{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);text-align:center;flex:1;padding:14px}.outcome-name{color:var(--text2);font-size:.875rem;font-family:var(--font-mono);margin-bottom:4px}.outcome-price{color:var(--text);font-size:1.25rem;font-weight:700;font-family:var(--font-mono)}.outcome-delta{font-size:.875rem;font-family:var(--font-mono);margin-top:2px}.outcome-card.yes .outcome-price{color:var(--green)}.outcome-card.no .outcome-price{color:var(--red)}.chart-container{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);flex:2;align-self:stretch;min-height:80px;padding:14px;overflow:hidden}.chart-label{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);margin-bottom:6px}.outcome-mini{flex-direction:column;align-items:center;gap:2px;padding:0 10px;display:flex}.outcome-mini-label{font-size:.72rem;font-family:var(--font-mono);color:var(--text3);text-transform:uppercase;letter-spacing:.06em}.outcome-mini-price{font-size:1rem;font-weight:700;font-family:var(--font-mono)}.outcome-mini-price.yes{color:var(--green)}.outcome-mini-price.no{color:var(--red)}.ai-box{background:var(--bg3);border:.5px solid var(--blue2);border-radius:var(--radius-sm);flex:1;align-items:flex-start;gap:14px;min-width:0;padding:14px;display:flex;overflow:hidden}.ai-icon{background:var(--blue3);border-radius:6px;flex-shrink:0;justify-content:center;align-items:center;width:32px;height:32px;font-size:.875rem;display:flex}.ai-label{color:var(--blue);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px}.ai-text{color:var(--text2);font-size:.9375rem;line-height:1.5}.sim-row{flex-wrap:wrap;justify-content:flex-end;align-items:center;gap:16px;display:flex}.sim-label{color:var(--text3);font-size:.9375rem;font-family:var(--font-mono)}.sim-input{background:var(--bg3);border:.5px solid var(--border2);border-radius:var(--radius-sm);color:var(--text);font-size:1rem;font-family:var(--font-mono);outline:none;width:100px;padding:8px 14px}.sim-input:focus{border-color:var(--blue2)}.sim-btn-yes{background:var(--green3);border:.5px solid var(--green2);color:var(--green);border-radius:var(--radius-sm);cursor:pointer;font-size:.9375rem;font-weight:600;font-family:var(--font-mono);padding:8px 16px;transition:opacity .15s}.sim-btn-no{background:var(--red3);border:.5px solid var(--red2);color:var(--red);border-radius:var(--radius-sm);cursor:pointer;font-size:.9375rem;font-weight:600;font-family:var(--font-mono);padding:8px 16px;transition:opacity .15s}.sim-btn-yes:hover,.sim-btn-no:hover{opacity:.85}.sim-disclaimer{color:var(--text3);font-size:.875rem;font-family:var(--font-mono)}.legend{align-items:center;gap:16px;display:flex}.legend-item{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);align-items:center;gap:5px;display:flex}.legend-dot{border-radius:50%;width:10px;height:10px}.legend-dot.green{background:var(--green)}.legend-dot.red{background:var(--red)}.legend-dot.gray{background:var(--text3)}.legend.end{margin-left:auto}.table-wrap{border:.5px solid var(--border);border-radius:var(--radius-sm);background:var(--bg3);overflow:auto}table{border-collapse:collapse;width:100%;font-size:1rem}th,td{text-align:left;border-bottom:.5px solid var(--border);padding:14px}th{font-family:var(--font-mono);color:var(--text3);text-transform:uppercase;letter-spacing:.06em;background:var(--bg4);font-size:.875rem;font-weight:500;position:sticky;top:0}tr:hover td{background:#ffffff05}td{color:var(--text)}.td-mono{font-family:var(--font-mono)}.td-green{color:var(--green)}.td-red{color:var(--red)}.td-blue{color:var(--blue)}.empty-state{text-align:center;color:var(--text3);padding:40px;font-size:1rem}#app .leaflet-container{background:var(--bg3);font-family:var(--font-mono)}#app .leaflet-popup-content-wrapper{background:var(--bg2);color:var(--text);border:.5px solid var(--border);border-radius:var(--radius-sm)}#app .leaflet-popup-tip{background:var(--bg2)}.sparkline{align-items:flex-end;gap:2px;height:32px;margin-top:6px;display:flex}.spark-bar{background:var(--blue2);border-radius:1px;width:3px;transition:height .3s}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}@keyframes scroll-stats{0%{transform:translate(0)}to{transform:translate(-50%)}}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--text3)}.flex-between{justify-content:space-between;align-items:center;display:flex}.flex-start{justify-content:space-between;align-items:flex-start;display:flex}.flex-row{align-items:center;display:flex}.flex-wrap{flex-wrap:wrap}.gap-6{gap:14px}.gap-8{gap:16px}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-blue{color:var(--blue)}.text-amber{color:var(--amber)}.text-neutral{color:var(--text3)}.bg-green{background:var(--green)}.bg-red{background:var(--red)}.bg-amber{background:var(--amber)}.flex-1{flex:1}.font-mono{font-family:var(--font-mono)}.text-xs,.text-sm{font-size:.875rem}.text-base{font-size:.9375rem}.text-lg{font-size:1rem}.text-xl{font-size:1.125rem}.font-semibold{font-weight:600}.font-bold{font-weight:700}.mb-4{margin-bottom:4px}.mb-6{margin-bottom:6px}.mb-8{margin-bottom:8px}.mt-4{margin-top:4px}.mt-6{margin-top:6px}.ml-auto{margin-left:auto}.divider{background:var(--border);height:1px;margin:8px 0}.empty-state-sm{text-align:center;color:var(--text3);padding:16px;font-size:1rem}.map-popup{font-family:var(--font-sans);color:var(--text);max-width:200px;font-size:.9375rem}.map-popup-cat{color:var(--text3);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;margin-bottom:4px}.map-popup-q{margin-bottom:4px;font-weight:600;line-height:1.3}.map-popup-prices{font-family:var(--font-mono);gap:16px;font-size:.9375rem;display:flex}.map-label-text{color:var(--text2);font-family:var(--font-mono);text-shadow:0 1px 2px #000;font-size:.875rem}.modal-overlay{-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:1000;opacity:1;background:#000000b3;justify-content:center;align-items:center;transition:opacity .2s;display:flex;position:fixed;inset:0}.modal-overlay.hidden{display:none}.modal{background:var(--bg2);border:.5px solid var(--border);border-radius:var(--radius);width:100%;max-width:420px;overflow:hidden}.modal-header{border-bottom:.5px solid var(--border);justify-content:space-between;align-items:center;padding:16px 20px;display:flex}.modal-tabs{gap:4px;display:flex}.modal-tab{color:var(--text2);font-family:var(--font-sans);border-radius:var(--radius-sm);cursor:pointer;background:0 0;border:none;padding:8px 16px;font-size:.9375rem;font-weight:500;transition:background .15s,color .15s}.modal-tab:hover{color:var(--text)}.modal-tab.active{background:var(--bg3);color:var(--text)}.modal-close{color:var(--text2);cursor:pointer;border-radius:var(--radius-sm);background:0 0;border:none;justify-content:center;align-items:center;width:32px;height:32px;font-size:1.125rem;transition:background .15s,color .15s;display:flex}.modal-close:hover{background:var(--bg3);color:var(--text)}.modal-body{padding:20px}.modal-form{flex-direction:column;gap:16px;display:none}.modal-form.active{display:flex}.form-group{flex-direction:column;gap:6px;display:flex}.form-group label{color:var(--text2);font-size:.875rem;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.06em}.form-group input{background:var(--bg3);border:.5px solid var(--border2);border-radius:var(--radius-sm);color:var(--text);font-size:.9375rem;font-family:var(--font-sans);outline:none;padding:12px 14px;transition:border-color .15s}.form-group input:focus{border-color:var(--blue2)}.form-group input::placeholder{color:var(--text3)}.form-error{color:var(--red);font-size:.875rem;font-family:var(--font-mono);min-height:18px}.modal-submit{color:#fff;font-family:var(--font-sans);border-radius:var(--radius-sm);cursor:pointer;background:#2563eb;border:none;margin-top:4px;padding:12px;font-size:.9375rem;font-weight:600;transition:opacity .15s}.modal-submit:hover{opacity:.9}.modal-title{color:var(--text);align-items:center;gap:10px;font-size:1rem;font-weight:600;display:flex}.modal-title-icon{font-size:1.125rem}.form-hint{color:var(--text3);font-size:.8125rem;line-height:1.4}.form-hint a{color:var(--blue);text-decoration:none}.form-hint a:hover{text-decoration:underline}.toggle-row{justify-content:space-between;align-items:center;display:flex}.toggle-switch{cursor:pointer;width:44px;height:24px;display:inline-block;position:relative}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{background:var(--bg4);border:.5px solid var(--border);border-radius:24px;transition:background .2s;position:absolute;inset:0}.toggle-slider:before{content:"";background:var(--text2);border-radius:50%;width:18px;height:18px;transition:transform .2s,background .2s;position:absolute;bottom:2px;left:2px}.toggle-switch input:checked+.toggle-slider{background:var(--blue2);border-color:var(--blue)}.toggle-switch input:checked+.toggle-slider:before{background:var(--text);transform:translate(20px)}.form-status{font-size:.875rem;font-family:var(--font-mono);min-height:18px;color:var(--text2)}.form-status.success{color:var(--green)}.form-status.error{color:var(--red)}.form-actions{gap:10px;margin-top:4px;display:flex}.modal-submit--secondary{background:var(--bg3);color:var(--text);border:.5px solid var(--border2)}.modal-submit--secondary:hover{background:var(--bg4);opacity:1}.telegram-instructions{border-bottom:.5px solid var(--border);flex-direction:column;gap:12px;margin-bottom:20px;padding-bottom:16px;display:flex}.instruction-step{align-items:flex-start;gap:12px;display:flex}.step-num{background:var(--blue3);width:24px;height:24px;color:var(--blue);border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;margin-top:2px;font-size:.8125rem;font-weight:600;display:flex}.step-body{flex:1}.step-body strong{color:var(--text);margin-bottom:2px;font-size:.875rem;font-weight:600;display:block}.step-body p{color:var(--text2);margin:0;font-size:.8125rem;line-height:1.5}.step-body a{color:var(--blue);text-decoration:none}.step-body a:hover{text-decoration:underline}.step-body code{background:var(--bg3);font-family:var(--font-mono);color:var(--text);border-radius:4px;padding:1px 5px;font-size:.75rem}.auth-indicator{background:var(--bg3);border:.5px solid var(--border2);width:32px;height:32px;color:var(--text2);cursor:pointer;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;padding:0;transition:color .15s,border-color .15s,background .2s;display:flex}.auth-indicator:after{content:"";background:var(--red);border-radius:50%;width:8px;height:8px;transition:background .2s;display:block}.auth-indicator.logged-in:after{background:var(--green)}.stats-track{flex:1;align-items:center;gap:20px;display:flex}.stats-track .legend.end~.stat,.stats-track .legend.end~.legend{display:none}.signals-sentinel{text-align:center;color:var(--text3);font-family:var(--font-mono);padding:12px;font-size:.875rem}#filter-trend{background:var(--bg3);border:.5px solid var(--amber2);min-width:170px;color:var(--amber)}#filter-trend:focus{border-color:var(--amber)}#filter-trend option{background:var(--bg2);color:var(--text)}.trend-badge{font-size:.75rem;font-weight:600;font-family:var(--font-mono);letter-spacing:.04em;border-radius:4px;margin-left:8px;padding:2px 6px}.trend-hot{color:var(--amber);border:.5px solid var(--amber2);background:#f0a02026}.trend-bull{background:var(--green3);color:var(--green);border:.5px solid var(--green2)}.trend-bear{background:var(--red3);color:var(--red);border:.5px solid var(--red2)}.trend-volatile{color:var(--blue);border:.5px solid var(--blue2);background:#4a9eff26}@media (width<=1024px){*{scrollbar-width:none;-ms-overflow-style:none}::-webkit-scrollbar{display:none}.dashboard-grid{grid-template-rows:auto auto auto;grid-template-columns:1fr}.signals-panel{grid-area:auto;max-height:400px}.map-panel{grid-area:auto;min-height:300px}.detail-panel{grid-area:auto}}@media (width<=640px){.layout{grid-template-rows:auto 1fr;grid-template-columns:0 1fr}.sidebar{display:none}.detail-header{flex-direction:column;gap:8px;margin-bottom:8px}.detail-metrics{justify-content:flex-start;width:100%}.metric{text-align:left}.outcomes-row{grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;display:grid}.outcomes-row .chart-container{grid-column:1/-1;height:130px;min-height:0;max-height:130px}.sim-row{grid-template-columns:1fr auto auto;align-items:center;gap:8px;display:grid}.sim-row .sim-label{grid-column:1/-1;font-size:.875rem}.sim-row .sim-input{width:100%;min-width:0}.sim-row .sim-btn-yes,.sim-row .sim-btn-no{white-space:nowrap;padding:8px 12px;font-size:.875rem}.sim-row .sim-disclaimer{grid-column:1/-1;font-size:.75rem}.desktop-only{display:none!important}.mobile-only{display:initial}.topbar{flex-wrap:wrap;gap:8px;height:auto;min-height:auto;padding:8px 12px}.topbar-logo{flex:1;order:0;height:auto;position:static}.topbar-actions{order:0;gap:10px;margin-left:0}.topbar-stats{flex:none;order:1;align-items:center;width:100%;display:flex;overflow:hidden}.topbar-stats .live-badge{display:none}.stats-track{flex:none;align-items:center;gap:24px;width:max-content;animation:20s linear infinite scroll-stats;display:flex;-webkit-mask-image:linear-gradient(90deg,#0000,#000 8% 92%,#0000);mask-image:linear-gradient(90deg,#0000,#000 8% 92%,#0000)}.stats-track:hover{animation-play-state:paused}.topbar-stats .stat{flex-shrink:0;gap:8px;padding:4px 0}.topbar-stats .stat-label{font-size:.75rem}.topbar-stats .stat-val{font-size:.875rem}.topbar-stats .stat-delta{font-size:.75rem}.topbar-stats .legend{display:none}.stats-track .legend.end~.stat{display:flex}.dashboard-grid{height:auto}.map-panel{min-height:250px}.signals-panel{max-height:none}.panel.collapsed{min-height:0;max-height:none}#btn-telegram-mobile{color:var(--blue);background:var(--blue3);border-color:var(--blue2)}#btn-notif,#btn-auth-mobile{display:flex}.topbar-logo .logo-text{font-size:1rem}#panel-map .panel-header{flex-wrap:wrap;gap:6px 0}#panel-map .panel-title{flex:1}.map-legend{order:1;justify-content:flex-end;width:100%;margin:0}#panel-signals .panel-header{z-index:10;background:var(--bg2);position:sticky;top:0}#panel-detail{display:none}.market-card-detail{background:var(--bg3);border:.5px solid var(--blue2);border-radius:var(--radius-sm);flex-direction:column;gap:12px;padding:14px;display:flex}}.leaflet-pane,.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile-container,.leaflet-pane>svg,.leaflet-pane>canvas,.leaflet-zoom-box,.leaflet-image-layer,.leaflet-layer{position:absolute;top:0;left:0}.leaflet-container{overflow:hidden}.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow{-webkit-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:0 0}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{-webkit-transform-origin:0 0;width:1600px;height:1600px}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer,.leaflet-container .leaflet-tile{width:auto;padding:0;max-width:none!important;max-height:none!important}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:#33b5e566}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{box-sizing:border-box;z-index:800;width:0;height:0}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{z-index:800;pointer-events:visiblePainted;pointer-events:auto;position:relative}.leaflet-top,.leaflet-bottom{z-index:1000;pointer-events:none;position:absolute}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-zoom-anim .leaflet-tile,.leaflet-pan-anim .leaflet-tile{transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-popup-pane,.leaflet-control{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-image-layer,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-marker-icon.leaflet-interactive,.leaflet-image-layer.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{outline-offset:1px;background:#ddd}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{background:#ffffff80;border:2px dotted #38f}.leaflet-container{font-family:Helvetica Neue,Arial,Helvetica,sans-serif;font-size:.75rem;line-height:1.5}.leaflet-bar{border-radius:4px;box-shadow:0 1px 5px #000000a6}.leaflet-bar a{text-align:center;color:#000;background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;text-decoration:none;display:block}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50%;background-repeat:no-repeat;display:block}.leaflet-bar a:hover,.leaflet-bar a:focus{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom:none;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.leaflet-bar a.leaflet-disabled{cursor:default;color:#bbb;background-color:#f4f4f4}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{text-indent:1px;font:700 18px Lucida Console,Monaco,monospace}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{background:#fff;border-radius:5px;box-shadow:0 1px 5px #0006}.leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{color:#333;background:#fff;padding:6px 10px 6px 6px}.leaflet-control-layers-scrollbar{padding-right:5px;overflow:hidden scroll}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{font-size:1.08333em;display:block}.leaflet-control-layers-separator{border-top:1px solid #ddd;height:0;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution{background:#fffc;margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{color:#333;padding:0 5px;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:hover,.leaflet-control-attribution a:focus{text-decoration:underline}.leaflet-attribution-flag{width:1em;height:.6669em;vertical-align:baseline!important;display:inline!important}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{white-space:nowrap;box-sizing:border-box;text-shadow:1px 1px #fff;background:#fffc;border:2px solid #777;border-top:none;padding:2px 5px 1px;line-height:1.1}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{box-shadow:none}.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{background-clip:padding-box;border:2px solid #0003}.leaflet-popup{text-align:center;margin-bottom:20px;position:absolute}.leaflet-popup-content-wrapper{text-align:left;border-radius:12px;padding:1px}.leaflet-popup-content{min-height:1px;margin:13px 24px 13px 20px;font-size:1.08333em;line-height:1.3}.leaflet-popup-content p{margin:1.3em 0}.leaflet-popup-tip-container{pointer-events:none;width:40px;height:20px;margin-top:-1px;margin-left:-20px;position:absolute;left:50%;overflow:hidden}.leaflet-popup-tip{pointer-events:auto;width:17px;height:17px;margin:-10px auto 0;padding:1px;transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{color:#333;background:#fff;box-shadow:0 3px 14px #0006}.leaflet-container a.leaflet-popup-close-button{text-align:center;color:#757575;background:0 0;border:none;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;text-decoration:none;position:absolute;top:0;right:0}.leaflet-container a.leaflet-popup-close-button:hover,.leaflet-container a.leaflet-popup-close-button:focus{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";width:24px;filter:progid:DXImageTransform.Microsoft.Matrix(M11=.707107, M12=.707107, M21=-.707107, M22=.707107);margin:0 auto}.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{color:#222;white-space:nowrap;-webkit-user-select:none;user-select:none;pointer-events:none;background-color:#fff;border:1px solid #fff;border-radius:3px;padding:6px;position:absolute;box-shadow:0 1px 3px #0006}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{pointer-events:none;content:"";background:0 0;border:6px solid #0000;position:absolute}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{margin-left:-6px;left:50%}.leaflet-tooltip-top:before{border-top-color:#fff;margin-bottom:-12px;bottom:0}.leaflet-tooltip-bottom:before{border-bottom-color:#fff;margin-top:-12px;margin-left:-6px;top:0}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{margin-top:-6px;top:50%}.leaflet-tooltip-left:before{border-left-color:#fff;margin-right:-12px;right:0}.leaflet-tooltip-right:before{border-right-color:#fff;margin-left:-12px;left:0}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
frontend/dist/assets/index-pjCx9cLl.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/dist/assets/index-xtYPhhTl.css DELETED
@@ -1 +0,0 @@
1
- @import "https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;500;600;700&display=swap";:root{--bg:#0a0c10;--bg2:#111318;--bg3:#181c24;--bg4:#1e232d;--border:#ffffff14;--border2:#ffffff21;--text:#e8eaf0;--text2:#8b90a0;--text3:#555b6e;--green:#22d37a;--green2:#0d6e3a;--green3:#052a17;--red:#f04040;--red2:#7a1a1a;--red3:#2d0808;--blue:#4a9eff;--blue2:#1a4a80;--blue3:#081830;--amber:#f0a020;--amber2:#7a4e08;--amber3:#2d1c02;--accent:#4a9eff;--sidebar-width:240px;--sidebar-collapsed:56px;--topbar-height:56px;--panel-gap:16px;--radius:10px;--radius-sm:6px;--fs-p:clamp(16px, 1.15vw, 18px);--fs-h1:clamp(28px, 2.8vw, 36px);--fs-h2:clamp(24px, 2.4vw, 30px);--fs-h3:clamp(20px, 1.9vw, 24px);--fs-h4:clamp(18px, 1.6vw, 20px);--fs-h5:clamp(16px, 1.3vw, 18px);--fs-h6:clamp(14px, 1.1vw, 16px)}*{box-sizing:border-box;margin:0;padding:0}html,body,#app{width:100%;height:100%;overflow:hidden}body{background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;font-family:Syne,sans-serif;font-size:16px;line-height:1.5}p{font-size:var(--fs-p);line-height:1.55}h1{font-size:var(--fs-h1);font-weight:700;line-height:1.15}h2{font-size:var(--fs-h2);font-weight:700;line-height:1.2}h3{font-size:var(--fs-h3);font-weight:600;line-height:1.25}h4{font-size:var(--fs-h4);font-weight:600;line-height:1.3}h5{font-size:var(--fs-h5);font-weight:500;line-height:1.35}h6{font-size:var(--fs-h6);font-weight:500;line-height:1.4}.layout{grid-template-areas:"topbar topbar""sidebar main";grid-template-columns:var(--sidebar-width) 1fr;grid-template-rows:var(--topbar-height) 1fr;width:100vw;height:100vh;transition:grid-template-columns .25s;display:grid}.layout.collapsed{grid-template-columns:var(--sidebar-collapsed) 1fr}.sidebar{background:var(--bg2);border-right:1px solid var(--border);flex-direction:column;grid-area:sidebar;transition:width .25s;display:flex;position:relative;overflow:hidden}.sidebar-toggle{background:var(--bg3);border:1px solid var(--border2);width:24px;height:24px;color:var(--text2);cursor:pointer;z-index:10;border-radius:50%;justify-content:center;align-items:center;font-size:14px;transition:transform .2s;display:flex;position:absolute;top:12px;right:-10px}.layout.collapsed .sidebar-toggle{right:-10px;transform:rotate(180deg)}.topbar-logo{align-items:center;gap:10px;display:flex;position:absolute;top:0;bottom:0;left:16px}.topbar-logo .logo-dot{background:var(--blue);border-radius:50%;flex-shrink:0;width:10px;height:10px;animation:2s ease-in-out infinite pulse}.topbar-logo .logo-text{color:var(--text);letter-spacing:-.3px;white-space:nowrap;font-size:18px;font-weight:700}.sidebar-nav{flex-direction:column;flex:1;gap:4px;padding:14px 8px;display:flex;overflow-y:auto}.nav-item{border-radius:var(--radius-sm);cursor:pointer;color:var(--text2);white-space:nowrap;align-items:center;gap:14px;padding:14px;font-size:16px;font-weight:500;transition:background .15s,color .15s;display:flex;overflow:hidden}.nav-item:hover{background:var(--bg3);color:var(--text)}.nav-item.active{background:var(--blue3);color:var(--blue);border:.5px solid var(--blue2)}.nav-icon{text-align:center;flex-shrink:0;width:20px;font-size:16px}.nav-label{opacity:1;transition:opacity .15s}.layout.collapsed .nav-label{opacity:0;width:0}.sidebar-footer{border-top:1px solid var(--border);color:var(--text3);text-align:center;white-space:nowrap;padding:14px;font-family:DM Mono,monospace;font-size:14px;overflow:hidden}.layout.collapsed .sidebar-footer{opacity:0}.topbar{background:var(--bg2);border-bottom:1px solid var(--border);padding:0 16px;padding-left:calc(var(--sidebar-width) + var(--panel-gap));grid-area:topbar;align-items:center;gap:16px;transition:padding-left .25s;display:flex;position:relative;overflow:hidden}.layout.collapsed .topbar{padding-left:calc(var(--sidebar-collapsed) + var(--panel-gap))}.live-badge{background:var(--green3);color:var(--green);border:.5px solid var(--green2);border-radius:4px;flex-shrink:0;align-items:center;gap:4px;padding:6px 12px;font-family:DM Mono,monospace;font-size:14px;display:flex}.live-dot{background:var(--green);border-radius:50%;width:8px;height:8px;animation:1.5s ease-in-out infinite pulse}.topbar-stats{flex:1;align-items:center;gap:20px;display:flex;overflow:hidden}.stat{flex-shrink:0;align-items:center;gap:14px;display:flex}.stat-label{color:var(--text3);text-transform:uppercase;letter-spacing:.06em;font-family:DM Mono,monospace;font-size:14px}.stat-val{color:var(--text);font-family:DM Mono,monospace;font-size:16px;font-weight:600}.stat-delta{font-family:DM Mono,monospace;font-size:14px}.stat-delta.up{color:var(--green)}.stat-delta.dn{color:var(--red)}.stat-delta.neutral{color:var(--text3)}.topbar-actions{flex-shrink:0;align-items:center;gap:16px;margin-left:auto;display:flex}.btn-ghost{color:var(--blue);border:.5px solid var(--blue2);background:var(--blue3);border-radius:var(--radius-sm);cursor:pointer;padding:8px 16px;font-family:Syne,sans-serif;font-size:15px;font-weight:500;transition:opacity .15s}.btn-ghost:hover{opacity:.85}.icon-btn{background:var(--bg3);border:.5px solid var(--border2);width:32px;height:32px;color:var(--text2);cursor:pointer;border-radius:50%;justify-content:center;align-items:center;font-size:16px;transition:color .15s,border-color .15s;display:flex}.icon-btn:hover{color:var(--text);border-color:var(--border2)}.main{padding:var(--panel-gap);background:var(--bg);grid-area:main;overflow:auto}.view{display:none}.view.active{height:100%;display:block}.dashboard-grid{gap:var(--panel-gap);grid-template-rows:1fr minmax(280px,40%);grid-template-columns:1fr 280px;height:100%;min-height:0;display:grid}.panel{background:var(--bg2);border:.5px solid var(--border);border-radius:var(--radius);flex-direction:column;min-height:0;display:flex;overflow:hidden}.panel-header{border-bottom:.5px solid var(--border);cursor:pointer;-webkit-user-select:none;user-select:none;justify-content:space-between;align-items:center;padding:14px;transition:background .15s;display:flex}.panel-header:hover{background:#ffffff05}.panel-title{color:var(--text3);text-transform:uppercase;letter-spacing:.08em;align-items:center;gap:14px;font-family:DM Mono,monospace;font-size:14px;display:flex}.panel-toggle{color:var(--text3);font-size:14px;transition:transform .2s}.panel.collapsed .panel-toggle{transform:rotate(-90deg)}.panel-body{flex:1;min-height:0;padding:12px;overflow:auto}.panel.collapsed .panel-body{display:none}.panel-subtitle{color:var(--text3);font-size:14px}.positions-separator{border-top:.5px solid var(--border);margin-top:10px;padding-top:10px}.panel-title.mb-sm{margin-bottom:8px}.panel.full-height{height:100%}.hidden{display:none}.map-panel{grid-area:1/1/2/2}#map-container{background:var(--bg3);border-radius:var(--radius-sm);width:100%;height:100%;min-height:300px;overflow:hidden}.signals-panel{grid-area:1/2/3/3}.signals-list{flex-direction:column;gap:16px;display:flex}.market-card{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;padding:14px;transition:border-color .15s,background .15s}.market-card:hover{border-color:var(--border2)}.market-card.active{border-color:var(--blue2);background:var(--blue3)}.market-cat{color:var(--text3);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;font-family:DM Mono,monospace;font-size:14px}.market-q{color:var(--text);margin-bottom:8px;font-size:15px;font-weight:500;line-height:1.4}.market-footer{justify-content:space-between;align-items:center;gap:16px;display:flex}.prob-bar-wrap{flex:1;min-width:0}.prob-bar-bg{background:var(--bg4);border-radius:2px;height:5px;overflow:hidden}.prob-bar-fill{height:100%;width:var(--prob-width,0%);border-radius:2px;transition:width .8s}.prob-val{flex-shrink:0;font-family:DM Mono,monospace;font-size:15px;font-weight:600}.signal-badge{letter-spacing:.04em;border-radius:4px;flex-shrink:0;padding:2px 6px;font-family:DM Mono,monospace;font-size:14px;font-weight:600}.sig-bull{background:var(--green3);color:var(--green);border:.5px solid var(--green2)}.sig-bear{background:var(--red3);color:var(--red);border:.5px solid var(--red2)}.sig-neut{background:var(--bg4);color:var(--text2);border:.5px solid var(--border2)}.detail-panel{grid-area:2/1/3/2;max-height:100%;overflow:hidden}.detail-panel .panel-body{overflow-y:auto}.detail-header{justify-content:space-between;align-items:flex-start;gap:16px;margin-bottom:12px;display:flex}.detail-tag{color:var(--blue);text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px;font-family:DM Mono,monospace;font-size:14px}.detail-q{color:var(--text);font-size:16px;font-weight:600;line-height:1.5}.detail-meta{color:var(--text3);margin-top:2px;font-family:DM Mono,monospace;font-size:14px}.detail-metrics{flex-shrink:0;align-items:center;gap:16px;display:flex}.metric{text-align:right}.metric-label{color:var(--text3);margin-bottom:2px;font-family:DM Mono,monospace;font-size:14px}.metric-value{font-family:DM Mono,monospace;font-size:14px;font-weight:700}.metric-sep{background:var(--border);width:1px;height:40px}.outcomes-row{gap:16px;margin-bottom:12px;display:flex}.outcome-card{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);text-align:center;flex:1;padding:14px}.outcome-name{color:var(--text2);margin-bottom:4px;font-family:DM Mono,monospace;font-size:14px}.outcome-price{color:var(--text);font-family:DM Mono,monospace;font-size:20px;font-weight:700}.outcome-delta{margin-top:2px;font-family:DM Mono,monospace;font-size:14px}.outcome-card.yes .outcome-price{color:var(--green)}.outcome-card.no .outcome-price{color:var(--red)}.chart-container{background:var(--bg3);border:.5px solid var(--border);border-radius:var(--radius-sm);flex:2;height:160px;min-height:120px;max-height:160px;padding:14px;overflow:hidden}.chart-container canvas{max-height:130px}.chart-label{color:var(--text3);margin-bottom:6px;font-family:DM Mono,monospace;font-size:14px}.ai-box{background:var(--bg3);border:.5px solid var(--blue2);border-radius:var(--radius-sm);align-items:flex-start;gap:14px;margin-bottom:10px;padding:14px;display:flex}.ai-icon{background:var(--blue3);border-radius:6px;flex-shrink:0;justify-content:center;align-items:center;width:32px;height:32px;font-size:14px;display:flex}.ai-label{color:var(--blue);text-transform:uppercase;letter-spacing:.06em;margin-bottom:3px;font-family:DM Mono,monospace;font-size:14px}.ai-text{color:var(--text2);font-size:15px;line-height:1.5}.sim-row{flex-wrap:wrap;align-items:center;gap:16px;display:flex}.sim-label{color:var(--text3);font-family:DM Mono,monospace;font-size:15px}.sim-input{background:var(--bg3);border:.5px solid var(--border2);border-radius:var(--radius-sm);color:var(--text);outline:none;width:100px;padding:8px 14px;font-family:DM Mono,monospace;font-size:16px}.sim-input:focus{border-color:var(--blue2)}.sim-btn-yes{background:var(--green3);border:.5px solid var(--green2);color:var(--green);border-radius:var(--radius-sm);cursor:pointer;padding:8px 16px;font-family:DM Mono,monospace;font-size:15px;font-weight:600;transition:opacity .15s}.sim-btn-no{background:var(--red3);border:.5px solid var(--red2);color:var(--red);border-radius:var(--radius-sm);cursor:pointer;padding:8px 16px;font-family:DM Mono,monospace;font-size:15px;font-weight:600;transition:opacity .15s}.sim-btn-yes:hover,.sim-btn-no:hover{opacity:.85}.sim-disclaimer{color:var(--text3);font-family:DM Mono,monospace;font-size:14px}.legend{align-items:center;gap:16px;display:flex}.legend-item{color:var(--text3);align-items:center;gap:5px;font-family:DM Mono,monospace;font-size:14px;display:flex}.legend-dot{border-radius:50%;width:10px;height:10px}.legend-dot.green{background:var(--green)}.legend-dot.red{background:var(--red)}.legend-dot.gray{background:var(--text3)}.legend.end{margin-left:auto}.table-wrap{border:.5px solid var(--border);border-radius:var(--radius-sm);background:var(--bg3);overflow:auto}table{border-collapse:collapse;width:100%;font-size:16px}th,td{text-align:left;border-bottom:.5px solid var(--border);padding:14px}th{color:var(--text3);text-transform:uppercase;letter-spacing:.06em;background:var(--bg4);font-family:DM Mono,monospace;font-size:14px;font-weight:500;position:sticky;top:0}tr:hover td{background:#ffffff05}td{color:var(--text)}.td-mono{font-family:DM Mono,monospace}.td-green{color:var(--green)}.td-red{color:var(--red)}.td-blue{color:var(--blue)}.empty-state{text-align:center;color:var(--text3);padding:40px;font-size:16px}#app .leaflet-container{background:var(--bg3);font-family:DM Mono,monospace}#app .leaflet-popup-content-wrapper{background:var(--bg2);color:var(--text);border:.5px solid var(--border);border-radius:var(--radius-sm)}#app .leaflet-popup-tip{background:var(--bg2)}.sparkline{align-items:flex-end;gap:2px;height:32px;margin-top:6px;display:flex}.spark-bar{background:var(--blue2);border-radius:1px;width:3px;transition:height .3s}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bg4);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--text3)}.flex-between{justify-content:space-between;align-items:center;display:flex}.flex-start{justify-content:space-between;align-items:flex-start;display:flex}.flex-row{align-items:center;display:flex}.flex-wrap{flex-wrap:wrap}.gap-6{gap:14px}.gap-8{gap:16px}.text-green{color:var(--green)}.text-red{color:var(--red)}.text-blue{color:var(--blue)}.text-amber{color:var(--amber)}.text-neutral{color:var(--text3)}.bg-green{background:var(--green)}.bg-red{background:var(--red)}.bg-amber{background:var(--amber)}.flex-1{flex:1}.font-mono{font-family:DM Mono,monospace}.text-xs,.text-sm{font-size:14px}.text-base{font-size:15px}.text-lg{font-size:16px}.text-xl{font-size:18px}.font-semibold{font-weight:600}.font-bold{font-weight:700}.mb-4{margin-bottom:4px}.mb-6{margin-bottom:6px}.mb-8{margin-bottom:8px}.mt-4{margin-top:4px}.mt-6{margin-top:6px}.ml-auto{margin-left:auto}.divider{background:var(--border);height:1px;margin:8px 0}.empty-state-sm{text-align:center;color:var(--text3);padding:16px;font-size:16px}.map-popup{color:var(--text);max-width:200px;font-family:Syne,sans-serif;font-size:15px}.map-popup-cat{color:var(--text3);text-transform:uppercase;margin-bottom:4px;font-family:DM Mono,monospace;font-size:14px}.map-popup-q{margin-bottom:4px;font-weight:600;line-height:1.3}.map-popup-prices{gap:16px;font-family:DM Mono,monospace;font-size:15px;display:flex}.map-label-text{color:var(--text2);text-shadow:0 1px 2px #000;font-family:DM Mono,monospace;font-size:14px}@media (width<=1024px){.dashboard-grid{grid-template-rows:auto auto auto;grid-template-columns:1fr}.signals-panel{grid-area:auto;max-height:400px}.map-panel{grid-area:auto;min-height:300px}.detail-panel{grid-area:auto}}@media (width<=640px){.layout{grid-template-columns:0 1fr}.sidebar,.topbar-stats{display:none}.outcomes-row{flex-direction:column}}.leaflet-pane,.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-tile-container,.leaflet-pane>svg,.leaflet-pane>canvas,.leaflet-zoom-box,.leaflet-image-layer,.leaflet-layer{position:absolute;top:0;left:0}.leaflet-container{overflow:hidden}.leaflet-tile,.leaflet-marker-icon,.leaflet-marker-shadow{-webkit-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile::selection{background:0 0}.leaflet-safari .leaflet-tile{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container{-webkit-transform-origin:0 0;width:1600px;height:1600px}.leaflet-marker-icon,.leaflet-marker-shadow{display:block}.leaflet-container .leaflet-overlay-pane svg{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img,.leaflet-container .leaflet-shadow-pane img,.leaflet-container .leaflet-tile-pane img,.leaflet-container img.leaflet-image-layer,.leaflet-container .leaflet-tile{width:auto;padding:0;max-width:none!important;max-height:none!important}.leaflet-container img.leaflet-tile{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom{-ms-touch-action:none;touch-action:none}.leaflet-container{-webkit-tap-highlight-color:transparent}.leaflet-container a{-webkit-tap-highlight-color:#33b5e566}.leaflet-tile{filter:inherit;visibility:hidden}.leaflet-tile-loaded{visibility:inherit}.leaflet-zoom-box{box-sizing:border-box;z-index:800;width:0;height:0}.leaflet-overlay-pane svg{-moz-user-select:none}.leaflet-pane{z-index:400}.leaflet-tile-pane{z-index:200}.leaflet-overlay-pane{z-index:400}.leaflet-shadow-pane{z-index:500}.leaflet-marker-pane{z-index:600}.leaflet-tooltip-pane{z-index:650}.leaflet-popup-pane{z-index:700}.leaflet-map-pane canvas{z-index:100}.leaflet-map-pane svg{z-index:200}.leaflet-vml-shape{width:1px;height:1px}.lvml{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control{z-index:800;pointer-events:visiblePainted;pointer-events:auto;position:relative}.leaflet-top,.leaflet-bottom{z-index:1000;pointer-events:none;position:absolute}.leaflet-top{top:0}.leaflet-right{right:0}.leaflet-bottom{bottom:0}.leaflet-left{left:0}.leaflet-control{float:left;clear:both}.leaflet-right .leaflet-control{float:right}.leaflet-top .leaflet-control{margin-top:10px}.leaflet-bottom .leaflet-control{margin-bottom:10px}.leaflet-left .leaflet-control{margin-left:10px}.leaflet-right .leaflet-control{margin-right:10px}.leaflet-fade-anim .leaflet-popup{opacity:0;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup{opacity:1}.leaflet-zoom-animated{transform-origin:0 0}svg.leaflet-zoom-animated{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-zoom-anim .leaflet-tile,.leaflet-pan-anim .leaflet-tile{transition:none}.leaflet-zoom-anim .leaflet-zoom-hide{visibility:hidden}.leaflet-interactive{cursor:pointer}.leaflet-grab{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair,.leaflet-crosshair .leaflet-interactive{cursor:crosshair}.leaflet-popup-pane,.leaflet-control{cursor:auto}.leaflet-dragging .leaflet-grab,.leaflet-dragging .leaflet-grab .leaflet-interactive,.leaflet-dragging .leaflet-marker-draggable{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-marker-icon,.leaflet-marker-shadow,.leaflet-image-layer,.leaflet-pane>svg path,.leaflet-tile-container{pointer-events:none}.leaflet-marker-icon.leaflet-interactive,.leaflet-image-layer.leaflet-interactive,.leaflet-pane>svg path.leaflet-interactive,svg.leaflet-image-layer.leaflet-interactive path{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container{outline-offset:1px;background:#ddd}.leaflet-container a{color:#0078a8}.leaflet-zoom-box{background:#ffffff80;border:2px dotted #38f}.leaflet-container{font-family:Helvetica Neue,Arial,Helvetica,sans-serif;font-size:.75rem;line-height:1.5}.leaflet-bar{border-radius:4px;box-shadow:0 1px 5px #000000a6}.leaflet-bar a{text-align:center;color:#000;background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;text-decoration:none;display:block}.leaflet-bar a,.leaflet-control-layers-toggle{background-position:50%;background-repeat:no-repeat;display:block}.leaflet-bar a:hover,.leaflet-bar a:focus{background-color:#f4f4f4}.leaflet-bar a:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a:last-child{border-bottom:none;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.leaflet-bar a.leaflet-disabled{cursor:default;color:#bbb;background-color:#f4f4f4}.leaflet-touch .leaflet-bar a{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.leaflet-control-zoom-in,.leaflet-control-zoom-out{text-indent:1px;font:700 18px Lucida Console,Monaco,monospace}.leaflet-touch .leaflet-control-zoom-in,.leaflet-touch .leaflet-control-zoom-out{font-size:22px}.leaflet-control-layers{background:#fff;border-radius:5px;box-shadow:0 1px 5px #0006}.leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list,.leaflet-control-layers-expanded .leaflet-control-layers-toggle{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list{display:block;position:relative}.leaflet-control-layers-expanded{color:#333;background:#fff;padding:6px 10px 6px 6px}.leaflet-control-layers-scrollbar{padding-right:5px;overflow:hidden scroll}.leaflet-control-layers-selector{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label{font-size:1.08333em;display:block}.leaflet-control-layers-separator{border-top:1px solid #ddd;height:0;margin:5px -10px 5px -6px}.leaflet-default-icon-path{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution{background:#fffc;margin:0}.leaflet-control-attribution,.leaflet-control-scale-line{color:#333;padding:0 5px;line-height:1.4}.leaflet-control-attribution a{text-decoration:none}.leaflet-control-attribution a:hover,.leaflet-control-attribution a:focus{text-decoration:underline}.leaflet-attribution-flag{width:1em;height:.6669em;vertical-align:baseline!important;display:inline!important}.leaflet-left .leaflet-control-scale{margin-left:5px}.leaflet-bottom .leaflet-control-scale{margin-bottom:5px}.leaflet-control-scale-line{white-space:nowrap;box-sizing:border-box;text-shadow:1px 1px #fff;background:#fffc;border:2px solid #777;border-top:none;padding:2px 5px 1px;line-height:1.1}.leaflet-control-scale-line:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-control-attribution,.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{box-shadow:none}.leaflet-touch .leaflet-control-layers,.leaflet-touch .leaflet-bar{background-clip:padding-box;border:2px solid #0003}.leaflet-popup{text-align:center;margin-bottom:20px;position:absolute}.leaflet-popup-content-wrapper{text-align:left;border-radius:12px;padding:1px}.leaflet-popup-content{min-height:1px;margin:13px 24px 13px 20px;font-size:1.08333em;line-height:1.3}.leaflet-popup-content p{margin:1.3em 0}.leaflet-popup-tip-container{pointer-events:none;width:40px;height:20px;margin-top:-1px;margin-left:-20px;position:absolute;left:50%;overflow:hidden}.leaflet-popup-tip{pointer-events:auto;width:17px;height:17px;margin:-10px auto 0;padding:1px;transform:rotate(45deg)}.leaflet-popup-content-wrapper,.leaflet-popup-tip{color:#333;background:#fff;box-shadow:0 3px 14px #0006}.leaflet-container a.leaflet-popup-close-button{text-align:center;color:#757575;background:0 0;border:none;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;text-decoration:none;position:absolute;top:0;right:0}.leaflet-container a.leaflet-popup-close-button:hover,.leaflet-container a.leaflet-popup-close-button:focus{color:#585858}.leaflet-popup-scrolled{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip{-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";width:24px;filter:progid:DXImageTransform.Microsoft.Matrix(M11=.707107, M12=.707107, M21=-.707107, M22=.707107);margin:0 auto}.leaflet-oldie .leaflet-control-zoom,.leaflet-oldie .leaflet-control-layers,.leaflet-oldie .leaflet-popup-content-wrapper,.leaflet-oldie .leaflet-popup-tip{border:1px solid #999}.leaflet-div-icon{background:#fff;border:1px solid #666}.leaflet-tooltip{color:#222;white-space:nowrap;-webkit-user-select:none;user-select:none;pointer-events:none;background-color:#fff;border:1px solid #fff;border-radius:3px;padding:6px;position:absolute;box-shadow:0 1px 3px #0006}.leaflet-tooltip.leaflet-interactive{cursor:pointer;pointer-events:auto}.leaflet-tooltip-top:before,.leaflet-tooltip-bottom:before,.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{pointer-events:none;content:"";background:0 0;border:6px solid #0000;position:absolute}.leaflet-tooltip-bottom{margin-top:6px}.leaflet-tooltip-top{margin-top:-6px}.leaflet-tooltip-bottom:before,.leaflet-tooltip-top:before{margin-left:-6px;left:50%}.leaflet-tooltip-top:before{border-top-color:#fff;margin-bottom:-12px;bottom:0}.leaflet-tooltip-bottom:before{border-bottom-color:#fff;margin-top:-12px;margin-left:-6px;top:0}.leaflet-tooltip-left{margin-left:-6px}.leaflet-tooltip-right{margin-left:6px}.leaflet-tooltip-left:before,.leaflet-tooltip-right:before{margin-top:-6px;top:50%}.leaflet-tooltip-left:before{border-left-color:#fff;margin-right:-12px;right:0}.leaflet-tooltip-right:before{border-right-color:#fff;margin-left:-12px;left:0}@media print{.leaflet-control{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
 
 
frontend/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal β€” Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
- <script type="module" crossorigin src="/assets/index-B7Y-b3uS.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-xtYPhhTl.css">
10
  </head>
11
  <body>
12
  <div id="app" class="layout">
@@ -43,39 +43,83 @@
43
  <div class="logo-dot"></div>
44
  <span class="logo-text">PolySignal</span>
45
  </div>
46
- <div class="live-badge">
47
- <div class="live-dot"></div>
48
- EN VIVO
49
- </div>
50
  <div class="topbar-stats">
51
- <div class="stat">
52
- <span class="stat-label">Mercados</span>
53
- <span class="stat-val" id="stat-markets">2.847</span>
54
- </div>
55
- <div class="stat">
56
- <span class="stat-label">Volumen 24h</span>
57
- <span class="stat-val" id="stat-volume">€4,2M</span>
58
- <span class="stat-delta up" id="stat-volume-delta">+12,4%</span>
59
- </div>
60
- <div class="stat">
61
- <span class="stat-label">SeΓ±ales IA</span>
62
- <span class="stat-val" id="stat-signals">183</span>
63
- <span class="stat-delta up" id="stat-signals-delta">alcista</span>
64
  </div>
65
- <div class="stat">
66
- <span class="stat-label">Alertas enviadas</span>
67
- <span class="stat-val" id="stat-alerts">47</span>
68
- <span class="stat-delta neutral">hoy</span>
69
- </div>
70
- <div class="legend end">
71
- <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
72
- <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
73
- <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  </div>
75
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  <div class="topbar-actions">
77
- <button class="btn-ghost" id="btn-telegram">Alertas Telegram</button>
 
78
  <button class="icon-btn" id="btn-notif" title="Notificaciones">β—‰</button>
 
 
79
  </div>
80
  </header>
81
 
@@ -91,8 +135,12 @@
91
  <div class="panel-header" data-panel="map">
92
  <div class="panel-title">
93
  <span>β—ˆ</span>
94
- Mapa global de predicciones
95
- <span class="panel-subtitle">Β· tamaΓ±o = volumen Β· color = seΓ±al IA</span>
 
 
 
 
96
  </div>
97
  <span class="panel-toggle">β–Ό</span>
98
  </div>
@@ -223,5 +271,115 @@
223
  </main>
224
  </div>
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  </body>
227
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>PolySignal β€” Dashboard de Inteligencia de Mercados</title>
7
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
8
+ <script type="module" crossorigin src="/assets/index-pjCx9cLl.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-D_WoXxRV.css">
10
  </head>
11
  <body>
12
  <div id="app" class="layout">
 
43
  <div class="logo-dot"></div>
44
  <span class="logo-text">PolySignal</span>
45
  </div>
 
 
 
 
46
  <div class="topbar-stats">
47
+ <div class="live-badge">
48
+ <div class="live-dot"></div>
49
+ EN VIVO
 
 
 
 
 
 
 
 
 
 
50
  </div>
51
+ <div class="stats-track">
52
+ <div class="stat">
53
+ <span class="stat-label">Mercados</span>
54
+ <span class="stat-val" id="stat-markets">2.847</span>
55
+ </div>
56
+ <div class="stat">
57
+ <span class="stat-label">Volumen 24h</span>
58
+ <span class="stat-val" id="stat-volume">€4,2M</span>
59
+ <span class="stat-delta up" id="stat-volume-delta">+12,4%</span>
60
+ </div>
61
+ <div class="stat">
62
+ <span class="stat-label">SeΓ±ales IA</span>
63
+ <span class="stat-val" id="stat-signals">183</span>
64
+ <span class="stat-delta up" id="stat-signals-delta">alcista</span>
65
+ </div>
66
+ <div class="stat">
67
+ <span class="stat-label">Alertas enviadas</span>
68
+ <span class="stat-val" id="stat-alerts">47</span>
69
+ <span class="stat-delta neutral">hoy</span>
70
+ </div>
71
+ <div class="legend end">
72
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
73
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
74
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
75
+ </div>
76
+ <!-- Duplicate for infinite scroll -->
77
+ <div class="stat">
78
+ <span class="stat-label">Mercados</span>
79
+ <span class="stat-val" id="stat-markets-dup">2.847</span>
80
+ </div>
81
+ <div class="stat">
82
+ <span class="stat-label">Volumen 24h</span>
83
+ <span class="stat-val" id="stat-volume-dup">€4,2M</span>
84
+ <span class="stat-delta up">+12,4%</span>
85
+ </div>
86
+ <div class="stat">
87
+ <span class="stat-label">SeΓ±ales IA</span>
88
+ <span class="stat-val" id="stat-signals-dup">183</span>
89
+ <span class="stat-delta up">alcista</span>
90
+ </div>
91
+ <div class="stat">
92
+ <span class="stat-label">Alertas enviadas</span>
93
+ <span class="stat-val" id="stat-alerts-dup">47</span>
94
+ <span class="stat-delta neutral">hoy</span>
95
+ </div>
96
+ <div class="legend">
97
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
98
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
99
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
100
+ </div>
101
  </div>
102
  </div>
103
+ <div class="topbar-filters desktop-only">
104
+ <select class="filter-select" id="filter-trend" title="Tendencias">
105
+ <option value="">πŸ”₯ Todos los mercados</option>
106
+ <option value="hot">πŸ”₯ MΓ‘s activos</option>
107
+ <option value="bullish-trend">πŸ“ˆ Tendencia alcista</option>
108
+ <option value="bearish-trend">πŸ“‰ Tendencia bajista</option>
109
+ <option value="volatile">⚑ MÑs volÑtiles</option>
110
+ <option value="high-volume">πŸ“Š Alto volumen</option>
111
+ </select>
112
+ <select class="filter-select" id="filter-category" title="CategorΓ­a">
113
+ <option value="">Todas las categorΓ­as</option>
114
+ </select>
115
+
116
+ </div>
117
  <div class="topbar-actions">
118
+ <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
119
+ <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
120
  <button class="icon-btn" id="btn-notif" title="Notificaciones">β—‰</button>
121
+ <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
122
+ <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
123
  </div>
124
  </header>
125
 
 
135
  <div class="panel-header" data-panel="map">
136
  <div class="panel-title">
137
  <span>β—ˆ</span>
138
+ Mapa global
139
+ </div>
140
+ <div class="map-legend">
141
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
142
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
143
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
144
  </div>
145
  <span class="panel-toggle">β–Ό</span>
146
  </div>
 
271
  </main>
272
  </div>
273
 
274
+ <!-- Telegram Alerts Modal -->
275
+ <div class="modal-overlay hidden" id="telegram-modal">
276
+ <div class="modal">
277
+ <div class="modal-header">
278
+ <div class="modal-title">
279
+ <span class="modal-title-icon">✈</span>
280
+ <span>Alertas Telegram</span>
281
+ </div>
282
+ <button class="modal-close" id="telegram-modal-close" title="Cerrar">βœ•</button>
283
+ </div>
284
+ <div class="modal-body">
285
+ <!-- Instructions -->
286
+ <div class="telegram-instructions">
287
+ <div class="instruction-step">
288
+ <span class="step-num">1</span>
289
+ <div class="step-body">
290
+ <strong>Crea tu bot</strong>
291
+ <p>Abre <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a> en Telegram, pulsa <em>/newbot</em> y sigue los pasos. Copia el token que te devuelve (parece <code>123456789:ABC...</code>).</p>
292
+ </div>
293
+ </div>
294
+ <div class="instruction-step">
295
+ <span class="step-num">2</span>
296
+ <div class="step-body">
297
+ <strong>ObtΓ©n tu Chat ID</strong>
298
+ <p>EscrΓ­bele a <a href="https://t.me/userinfobot" target="_blank" rel="noopener">@userinfobot</a> y te responderΓ‘ con tu ID. Si quieres enviarlo a un grupo, aΓ±ade el bot al grupo y usa <a href="https://t.me/getidsbot" target="_blank" rel="noopener">@getidsbot</a>.</p>
299
+ </div>
300
+ </div>
301
+ <div class="instruction-step">
302
+ <span class="step-num">3</span>
303
+ <div class="step-body">
304
+ <strong>Inicia el bot</strong>
305
+ <p>En Telegram, abre una conversaciΓ³n con tu bot y pulsa <em>/start</em>. Si usas un grupo, envΓ­a <em>/start</em> en el grupo tambiΓ©n.</p>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
310
+ <form class="modal-form active" id="form-telegram">
311
+ <div class="form-group">
312
+ <label for="telegram-bot-token">Token del bot</label>
313
+ <input type="password" id="telegram-bot-token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" />
314
+ </div>
315
+ <div class="form-group">
316
+ <label for="telegram-chat-id">Chat ID</label>
317
+ <input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
318
+ </div>
319
+ <div class="form-group">
320
+ <div class="toggle-row">
321
+ <label for="telegram-enabled">Activar alertas</label>
322
+ <label class="toggle-switch">
323
+ <input type="checkbox" id="telegram-enabled" />
324
+ <span class="toggle-slider"></span>
325
+ </label>
326
+ </div>
327
+ </div>
328
+ <div class="form-status" id="telegram-status"></div>
329
+ <div class="form-actions">
330
+ <button type="button" class="modal-submit modal-submit--secondary" id="btn-test-telegram">Probar conexiΓ³n</button>
331
+ <button type="submit" class="modal-submit">Guardar configuraciΓ³n</button>
332
+ </div>
333
+ </form>
334
+ </div>
335
+ </div>
336
+ </div>
337
+
338
+ <!-- Auth Modal -->
339
+ <div class="modal-overlay hidden" id="auth-modal">
340
+ <div class="modal">
341
+ <div class="modal-header">
342
+ <div class="modal-tabs">
343
+ <button class="modal-tab active" data-tab="login">Iniciar sesiΓ³n</button>
344
+ <button class="modal-tab" data-tab="register">Registrarse</button>
345
+ </div>
346
+ <button class="modal-close" id="modal-close" title="Cerrar">βœ•</button>
347
+ </div>
348
+ <div class="modal-body">
349
+ <!-- Login Form -->
350
+ <form class="modal-form active" id="form-login">
351
+ <div class="form-group">
352
+ <label for="login-email">Correo electrΓ³nico</label>
353
+ <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
354
+ </div>
355
+ <div class="form-group">
356
+ <label for="login-password">ContraseΓ±a</label>
357
+ <input type="password" id="login-password" placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" required />
358
+ </div>
359
+ <div class="form-error" id="login-error"></div>
360
+ <button type="submit" class="modal-submit">Entrar</button>
361
+ </form>
362
+
363
+ <!-- Register Form -->
364
+ <form class="modal-form" id="form-register">
365
+ <div class="form-group">
366
+ <label for="register-email">Correo electrΓ³nico</label>
367
+ <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
368
+ </div>
369
+ <div class="form-group">
370
+ <label for="register-password">ContraseΓ±a</label>
371
+ <input type="password" id="register-password" placeholder="MΓ­nimo 8 caracteres" required minlength="8" />
372
+ </div>
373
+ <div class="form-group">
374
+ <label for="register-password-confirm">Confirmar contraseΓ±a</label>
375
+ <input type="password" id="register-password-confirm" placeholder="Repite la contraseΓ±a" required />
376
+ </div>
377
+ <div class="form-error" id="register-error"></div>
378
+ <button type="submit" class="modal-submit">Crear cuenta</button>
379
+ </form>
380
+ </div>
381
+ </div>
382
+ </div>
383
+
384
  </body>
385
  </html>
frontend/index.html CHANGED
@@ -98,6 +98,20 @@
98
  </div>
99
  </div>
100
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  <div class="topbar-actions">
102
  <button class="btn-ghost desktop-only" id="btn-telegram">Alertas Telegram</button>
103
  <button class="icon-btn mobile-only" id="btn-telegram-mobile" title="Alertas Telegram">✈</button>
 
98
  </div>
99
  </div>
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>
112
+ </select>
113
+
114
+ </div>
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>
frontend/src/api.js CHANGED
@@ -69,6 +69,10 @@ export function logout() {
69
  clearToken()
70
  }
71
 
 
 
 
 
72
  /* ─── Core fetch ─── */
73
  async function fetchJson(url, opts = {}) {
74
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
@@ -86,6 +90,7 @@ async function fetchJson(url, opts = {}) {
86
  })
87
 
88
  if (!res.ok) {
 
89
  const text = await res.text().catch(() => '')
90
  throw new Error(`HTTP ${res.status}: ${text}`)
91
  }
@@ -110,6 +115,10 @@ export async function getMarket(id) {
110
  return fetchJson(`${BASE}/markets/${id}`)
111
  }
112
 
 
 
 
 
113
  /* ─── Signals ─── */
114
  export async function getSignal(marketId) {
115
  return fetchJson(`${BASE}/markets/${marketId}/signal`)
@@ -126,6 +135,10 @@ export async function getPositions() {
126
  return fetchJson(`${BASE}/positions`)
127
  }
128
 
 
 
 
 
129
  export async function createPosition(data) {
130
  return fetchJson(`${BASE}/positions`, {
131
  method: 'POST',
 
69
  clearToken()
70
  }
71
 
72
+ export async function getMe() {
73
+ return fetchJson(`${BASE}/auth/me`)
74
+ }
75
+
76
  /* ─── Core fetch ─── */
77
  async function fetchJson(url, opts = {}) {
78
  const headers = { 'Content-Type': 'application/json', ...opts.headers }
 
90
  })
91
 
92
  if (!res.ok) {
93
+ if (res.status === 401) clearToken()
94
  const text = await res.text().catch(() => '')
95
  throw new Error(`HTTP ${res.status}: ${text}`)
96
  }
 
115
  return fetchJson(`${BASE}/markets/${id}`)
116
  }
117
 
118
+ export async function getMarketHistory(id, interval = '1w') {
119
+ return fetchJson(`${BASE}/markets/${id}/history?interval=${interval}`)
120
+ }
121
+
122
  /* ─── Signals ─── */
123
  export async function getSignal(marketId) {
124
  return fetchJson(`${BASE}/markets/${marketId}/signal`)
 
135
  return fetchJson(`${BASE}/positions`)
136
  }
137
 
138
+ export async function getPositionSuggestion(marketId, bankroll = 1000) {
139
+ return fetchJson(`${BASE}/positions/suggestion/${marketId}?bankroll=${bankroll}`)
140
+ }
141
+
142
  export async function createPosition(data) {
143
  return fetchJson(`${BASE}/positions`, {
144
  method: 'POST',
frontend/src/app.js CHANGED
@@ -30,6 +30,7 @@ import * as api from './api.js'
30
  import * as charts from './charts.js'
31
  import * as map from './map.js'
32
  import * as simulator from './simulator.js'
 
33
 
34
  /* ─── Estado global ─── */
35
  let state = {
@@ -45,6 +46,8 @@ let state = {
45
  signalsOffset: 0,
46
  signalsHasMore: true,
47
  signalsLoading: false,
 
 
48
  }
49
 
50
  let signalsObserver = null
@@ -109,6 +112,134 @@ function emptyState(text, sm = false) {
109
  return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
110
  }
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  /* ─── Auth Modal ─── */
113
  function openAuthModal() {
114
  document.getElementById('auth-modal')?.classList.remove('hidden')
@@ -290,8 +421,14 @@ async function handleRegister(e) {
290
  }
291
 
292
  async function ensureAuth() {
293
- if (api.isAuthenticated()) return true
294
- return false
 
 
 
 
 
 
295
  }
296
 
297
  /* ─── Routing de vistas ─── */
@@ -321,14 +458,27 @@ function togglePanel(panelId) {
321
 
322
  /* ─── Signal card factory ─── */
323
  function makeSignalCard(m) {
324
- const sig = state.signals.find((s) => s.marketId === m.id) || { signal: 'neutral', confidence: 0.5 }
325
- const cls = signalColorClass(sig.signal)
 
326
 
327
  const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
328
  card.dataset.market = m.id
329
 
330
  const cat = el('div', 'market-cat')
331
- cat.textContent = `${m.category || 'General'} Β· ${m.countryCode || 'GL'}`
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  const q = el('div', 'market-q')
334
  q.textContent = m.question
@@ -345,11 +495,57 @@ function makeSignalCard(m) {
345
  const probVal = el('span', `prob-val text-${cls}`)
346
  probVal.textContent = formatPrice(m.yesPrice)
347
 
348
- const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
349
- badge.textContent = getSignalLabel(sig.signal)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
 
351
- footer.append(probWrap, probVal, badge)
352
  card.append(cat, q, footer)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  card.addEventListener('click', () => selectMarket(card.dataset.market))
354
  return card
355
  }
@@ -372,19 +568,23 @@ function attachSignalsObserver(container) {
372
 
373
  /* ─── Render signals list ─── */
374
  function renderSignals() {
 
 
 
 
 
375
  const container = document.getElementById('signals-list')
376
  if (!container) return
377
 
378
  if (signalsObserver) { signalsObserver.disconnect(); signalsObserver = null }
379
  container.replaceChildren()
380
 
381
- if (state.markets.length === 0) {
382
- container.appendChild(emptyState('No hay mercados disponibles'))
383
  return
384
  }
385
 
386
- state.markets.forEach((m) => container.appendChild(makeSignalCard(m)))
387
- if (state.signalsHasMore) attachSignalsObserver(container)
388
  }
389
 
390
  function appendSignalCards(newMarkets) {
@@ -462,44 +662,43 @@ function buildDetailDOM(m, sig, prefix = '') {
462
  const metricConf = el('div', 'metric')
463
  metricConf.append(el('div', 'metric-label', 'Confianza'), confEl)
464
 
 
 
 
 
 
 
 
 
 
 
 
465
  const metrics = el('div', 'detail-metrics')
466
- metrics.append(metricDelta, el('div', 'metric-sep'), metricConf)
467
 
468
  const header = el('div', 'detail-header')
469
  header.append(headerLeft, metrics)
470
 
471
- // ── Outcomes row ──
472
- const sparkYes = el('div', 'sparkline')
473
- sparkYes.id = sparkYesId
474
- const yesPriceEl = el('div', 'outcome-price')
475
- yesPriceEl.textContent = formatPrice(m.yesPrice)
476
- const yesDeltaEl = el('div', 'outcome-delta td-green')
477
- yesDeltaEl.textContent = `β–² ${((m.yesPrice || 0) * 0.05).toFixed(1)}Β’`
478
- const yesCard = el('div', 'outcome-card yes')
479
- yesCard.append(el('div', 'outcome-name', 'SÍ'), yesPriceEl, yesDeltaEl, sparkYes)
480
-
481
- const sparkNo = el('div', 'sparkline')
482
- sparkNo.id = sparkNoId
483
- const noPriceEl = el('div', 'outcome-price')
484
- noPriceEl.textContent = formatPrice(m.noPrice)
485
- const noDeltaEl = el('div', 'outcome-delta td-red')
486
- noDeltaEl.textContent = `β–Ό ${((m.noPrice || 0) * 0.05).toFixed(1)}Β’`
487
- const noCard = el('div', 'outcome-card no')
488
- noCard.append(el('div', 'outcome-name', 'NO'), noPriceEl, noDeltaEl, sparkNo)
489
-
490
  const detailChart = el('canvas')
491
  detailChart.id = chartId
492
  const chartContainer = el('div', 'chart-container')
493
  chartContainer.append(el('div', 'chart-label', 'Historial de precios 7d'), detailChart)
494
 
495
  const outcomesRow = el('div', 'outcomes-row')
496
- outcomesRow.append(yesCard, noCard, chartContainer)
497
 
498
  // ── AI box ──
499
  const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
500
  aiBadge.textContent = `${translateSignal(sig.signal).toUpperCase()} Β· ${Math.round(sig.confidence * 100)}%`
 
 
 
 
 
 
 
501
  const aiHeader = el('div', 'flex-between mb-4')
502
- aiHeader.append(el('div', 'ai-label', 'AnΓ‘lisis IA'), aiBadge)
503
 
504
  // Texto IA construido con nodos DOM β€” ninguna cadena externa toca innerHTML
505
  const aiText = el('div', 'ai-text')
@@ -510,12 +709,13 @@ function buildDetailDOM(m, sig, prefix = '') {
510
  aiText.append(' ', strong, ' ', sig.keyRisk)
511
  }
512
 
513
- const aiInner = el('div', 'flex-1')
514
- aiInner.append(aiHeader, aiText)
515
  const aiBox = el('div', 'ai-box')
516
- aiBox.append(el('div', 'ai-icon', 'β—ˆ'), aiInner)
 
 
517
 
518
  // ── Simulator row ──
 
519
  const simAmount = el('input', 'sim-input')
520
  simAmount.type = 'number'
521
  simAmount.value = '100'
@@ -525,8 +725,12 @@ function buildDetailDOM(m, sig, prefix = '') {
525
  const simYes = el('button', 'sim-btn-yes', 'COMPRAR SÍ β†—')
526
  const simNo = el('button', 'sim-btn-no', 'COMPRAR NO')
527
 
 
 
 
528
  const simRow = el('div', 'sim-row')
529
  simRow.append(
 
530
  el('span', 'sim-label', 'Simular posiciΓ³n β†’'),
531
  simAmount,
532
  simYes,
@@ -537,7 +741,25 @@ function buildDetailDOM(m, sig, prefix = '') {
537
  simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
538
 
539
  const content = el('div')
540
- content.append(header, outcomesRow, aiBox, simRow)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
  return { content, chartId, sparkYesId, sparkNoId }
543
  }
@@ -548,7 +770,7 @@ function renderDetail() {
548
  if (!container) return
549
  const m = state.markets.find((x) => x.id === state.activeMarketId)
550
  if (!m) {
551
- container.replaceChildren(emptyState('Selecciona un mercado para ver detalles'))
552
  return
553
  }
554
 
@@ -562,7 +784,11 @@ function renderDetail() {
562
  const { content, chartId, sparkYesId, sparkNoId } = buildDetailDOM(m, sig)
563
  container.replaceChildren(content)
564
 
565
- charts.renderDetailChart(chartId, m.yesPrice)
 
 
 
 
566
  charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
567
  charts.renderSparkline(sparkNoId, m.noPrice, 'no')
568
  }
@@ -607,7 +833,11 @@ function selectMarket(marketId) {
607
  // Charts require the canvas to be in the DOM before rendering.
608
  // Scroll after paint so the layout height is settled.
609
  requestAnimationFrame(() => {
610
- charts.renderDetailChart(chartId, m.yesPrice)
 
 
 
 
611
  charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
612
  charts.renderSparkline(sparkNoId, m.noPrice, 'no')
613
 
@@ -789,10 +1019,10 @@ function renderAlerts() {
789
  /* ─── Carga de datos ─── */
790
  async function loadMarkets() {
791
  try {
792
- const batch = await api.getMarkets({ limit: 20, offset: 0 })
793
  state.markets = Array.isArray(batch) ? batch : []
794
  state.signalsOffset = state.markets.length
795
- state.signalsHasMore = state.markets.length === 20
796
  } catch (e) {
797
  console.error('Error cargando mercados:', e)
798
  state.markets = []
@@ -805,7 +1035,7 @@ async function loadMoreMarkets() {
805
  if (state.signalsLoading || !state.signalsHasMore) return
806
  state.signalsLoading = true
807
  try {
808
- const batch = await api.getMarkets({ limit: 20, offset: state.signalsOffset })
809
  const arr = Array.isArray(batch) ? batch : []
810
  if (arr.length === 0) {
811
  state.signalsHasMore = false
@@ -814,12 +1044,13 @@ async function loadMoreMarkets() {
814
  } else {
815
  state.markets.push(...arr)
816
  state.signalsOffset += arr.length
817
- state.signalsHasMore = arr.length === 20
818
  try {
819
  const newSigs = await api.getSignalsBatch(arr.map((m) => m.id))
820
  state.signals.push(...newSigs.map((r) => ({ ...r, marketId: r.marketId })))
821
  } catch (e) { /* signals are optional */ }
822
- appendSignalCards(arr)
 
823
  }
824
  } catch (e) {
825
  console.error('Error cargando mΓ‘s mercados:', e)
@@ -895,6 +1126,14 @@ async function initAppData() {
895
  await loadAlerts()
896
  await loadStats()
897
 
 
 
 
 
 
 
 
 
898
  map.init('map-container', state.markets, state.signals, selectMarket)
899
  simulator.init(state)
900
 
@@ -947,6 +1186,7 @@ export async function init() {
947
  const authed = await ensureAuth()
948
  if (authed) {
949
  await initAppData()
 
950
  } else {
951
  openAuthModal()
952
  }
@@ -958,7 +1198,10 @@ export async function init() {
958
  const m = state.markets.find((x) => x.id === data.marketId)
959
  if (m) {
960
  // Solo copia campos numericos conocidos β€” nunca mezcle todo el payload
961
- if (typeof data.yesPrice === 'number') m.yesPrice = data.yesPrice
 
 
 
962
  if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
963
  if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
964
  if (typeof data.liquidityEur === 'number') m.liquidityEur = data.liquidityEur
 
30
  import * as charts from './charts.js'
31
  import * as map from './map.js'
32
  import * as simulator from './simulator.js'
33
+ import { extractFilterOptions, filterMarkets } from './filters.js'
34
 
35
  /* ─── Estado global ─── */
36
  let state = {
 
46
  signalsOffset: 0,
47
  signalsHasMore: true,
48
  signalsLoading: false,
49
+ filters: { category: '', trend: '' },
50
+ priceHistory: new Map(), // marketId β†’ [{price, timestamp}]
51
  }
52
 
53
  let signalsObserver = null
 
112
  return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
113
  }
114
 
115
+ /* ─── Filters ─── */
116
+ function populateFilters() {
117
+ const opts = extractFilterOptions(state.markets)
118
+
119
+ const catSel = document.getElementById('filter-category')
120
+ if (!catSel) return
121
+
122
+ const currentCat = catSel.value
123
+ catSel.innerHTML = '<option value="">Todas las categorΓ­as</option>' +
124
+ opts.categories.slice(1).map((c) => `<option value="${c}">${c.charAt(0).toUpperCase() + c.slice(1)}</option>`).join('')
125
+ catSel.value = opts.categories.includes(currentCat) ? currentCat : ''
126
+ }
127
+
128
+ function applyFilters() {
129
+ state.filters.category = document.getElementById('filter-category')?.value || ''
130
+ state.filters.trend = document.getElementById('filter-trend')?.value || ''
131
+
132
+ let filtered = filterMarkets(state.markets, state.filters)
133
+ if (state.filters.trend) {
134
+ filtered = filterByTrend(filtered, state.filters.trend)
135
+ }
136
+ renderSignalsFiltered(filtered)
137
+ map.updateMarkers(filtered, state.signals)
138
+ }
139
+
140
+ function initFilters() {
141
+ populateFilters()
142
+ document.getElementById('filter-category')?.addEventListener('change', applyFilters)
143
+ document.getElementById('filter-trend')?.addEventListener('change', applyFilters)
144
+ }
145
+
146
+ /* ─── Trend Tracking ─── */
147
+ function recordPrice(marketId, price) {
148
+ if (!state.priceHistory.has(marketId)) {
149
+ state.priceHistory.set(marketId, [])
150
+ }
151
+ const history = state.priceHistory.get(marketId)
152
+ history.push({ price, timestamp: Date.now() })
153
+ // Mantener solo ΓΊltimos 20 registros (~10 min con sync cada 30s)
154
+ if (history.length > 20) history.shift()
155
+ }
156
+
157
+ function getMarketTrend(marketId) {
158
+ const history = state.priceHistory.get(marketId)
159
+ if (!history || history.length < 2) return { momentum: 0, volatility: 0, avgVolume: 0 }
160
+
161
+ const prices = history.map((h) => h.price)
162
+ const first = prices[0]
163
+ const last = prices[prices.length - 1]
164
+ const momentum = first !== 0 ? ((last - first) / first) * 100 : 0
165
+
166
+ // Volatilidad = desviaciΓ³n estΓ‘ndar de cambios porcentuales
167
+ let volatility = 0
168
+ if (prices.length >= 3) {
169
+ const changes = []
170
+ for (let i = 1; i < prices.length; i++) {
171
+ if (prices[i - 1] !== 0) {
172
+ changes.push(((prices[i] - prices[i - 1]) / prices[i - 1]) * 100)
173
+ }
174
+ }
175
+ if (changes.length > 1) {
176
+ const mean = changes.reduce((a, b) => a + b, 0) / changes.length
177
+ const variance = changes.reduce((sum, c) => sum + Math.pow(c - mean, 2), 0) / changes.length
178
+ volatility = Math.sqrt(variance)
179
+ }
180
+ }
181
+
182
+ return { momentum, volatility }
183
+ }
184
+
185
+ function filterByTrend(markets, trendType) {
186
+ if (!trendType) return markets
187
+
188
+ // Precalcular trends
189
+ const withTrend = markets.map((m) => {
190
+ const trend = getMarketTrend(m.id)
191
+ const sig = state.signals.find((s) => s.marketId === m.id)
192
+ return {
193
+ market: m,
194
+ momentum: trend.momentum,
195
+ volatility: trend.volatility,
196
+ volume: m.volumeEur || 0,
197
+ signal: sig?.signal || 'neutral',
198
+ confidence: sig?.confidence || 0.5,
199
+ }
200
+ })
201
+
202
+ switch (trendType) {
203
+ case 'hot':
204
+ // MΓ‘s activos = mayor volumen + algΓΊn movimiento reciente
205
+ return withTrend
206
+ .filter((w) => w.volume > 100000 || Math.abs(w.momentum) > 1)
207
+ .sort((a, b) => b.volume - a.volume)
208
+ .map((w) => w.market)
209
+
210
+ case 'bullish-trend':
211
+ // Tendencia alcista = momentum positivo + seΓ±al bullish
212
+ return withTrend
213
+ .filter((w) => w.momentum > 0.5 || w.signal === 'bullish')
214
+ .sort((a, b) => b.momentum - a.momentum)
215
+ .map((w) => w.market)
216
+
217
+ case 'bearish-trend':
218
+ // Tendencia bajista = momentum negativo + seΓ±al bearish
219
+ return withTrend
220
+ .filter((w) => w.momentum < -0.5 || w.signal === 'bearish')
221
+ .sort((a, b) => a.momentum - b.momentum)
222
+ .map((w) => w.market)
223
+
224
+ case 'volatile':
225
+ // MΓ‘s volΓ‘tiles = mayor desviaciΓ³n estΓ‘ndar de cambios
226
+ return withTrend
227
+ .filter((w) => w.volatility > 0.3)
228
+ .sort((a, b) => b.volatility - a.volatility)
229
+ .map((w) => w.market)
230
+
231
+ case 'high-volume':
232
+ // Alto volumen
233
+ return withTrend
234
+ .filter((w) => w.volume > 500000)
235
+ .sort((a, b) => b.volume - a.volume)
236
+ .map((w) => w.market)
237
+
238
+ default:
239
+ return markets
240
+ }
241
+ }
242
+
243
  /* ─── Auth Modal ─── */
244
  function openAuthModal() {
245
  document.getElementById('auth-modal')?.classList.remove('hidden')
 
421
  }
422
 
423
  async function ensureAuth() {
424
+ if (!api.isAuthenticated()) return false
425
+ try {
426
+ await api.getMe()
427
+ return true
428
+ } catch (e) {
429
+ // Token invΓ‘lido o expirado β€” ya fue borrado por fetchJson
430
+ return false
431
+ }
432
  }
433
 
434
  /* ─── Routing de vistas ─── */
 
458
 
459
  /* ─── Signal card factory ─── */
460
  function makeSignalCard(m) {
461
+ const sig = state.signals.find((s) => s.marketId === m.id) || null
462
+ const hasSignal = sig != null
463
+ const cls = signalColorClass(sig?.signal || 'neutral')
464
 
465
  const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
466
  card.dataset.market = m.id
467
 
468
  const cat = el('div', 'market-cat')
469
+ const catLabel = `${m.category || 'General'} Β· ${m.countryCode || 'GL'}`
470
+ cat.textContent = catLabel
471
+
472
+ // Spread badge si Polymarket reporta uno wide
473
+ if (m.spread != null && m.spread > 0.05) {
474
+ const spreadBadge = el('span', 'spread-badge illiquid')
475
+ spreadBadge.textContent = `Β· ilΓ­quido ${Math.round(m.spread * 100)}Β’`
476
+ cat.appendChild(spreadBadge)
477
+ } else if (m.spread != null && m.spread > 0.02) {
478
+ const spreadBadge = el('span', 'spread-badge')
479
+ spreadBadge.textContent = `Β· spread ${Math.round(m.spread * 100)}Β’`
480
+ cat.appendChild(spreadBadge)
481
+ }
482
 
483
  const q = el('div', 'market-q')
484
  q.textContent = m.question
 
495
  const probVal = el('span', `prob-val text-${cls}`)
496
  probVal.textContent = formatPrice(m.yesPrice)
497
 
498
+ if (hasSignal) {
499
+ const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
500
+ badge.textContent = getSignalLabel(sig.signal)
501
+
502
+ const trend = getMarketTrend(m.id)
503
+ if (Math.abs(trend.momentum) > 2 || trend.volatility > 1) {
504
+ let trendBadgeClass = 'trend-volatile'
505
+ let trendText = '⚑'
506
+ if (trend.momentum > 3) { trendBadgeClass = 'trend-bull'; trendText = 'β–²' }
507
+ else if (trend.momentum < -3) { trendBadgeClass = 'trend-bear'; trendText = 'β–Ό' }
508
+ else if (trend.volatility > 1.5) { trendBadgeClass = 'trend-volatile'; trendText = '⚑' }
509
+ const trendBadge = el('span', `trend-badge ${trendBadgeClass}`)
510
+ trendBadge.textContent = trendText
511
+ footer.append(probWrap, probVal, badge, trendBadge)
512
+ } else {
513
+ footer.append(probWrap, probVal, badge)
514
+ }
515
+ } else {
516
+ const placeholder = el('span', 'signal-badge sig-none')
517
+ placeholder.textContent = m.analyzable === false ? 'FUERA DE ALCANCE' : 'SIN ANÁLISIS'
518
+ placeholder.title = m.analyzable === false
519
+ ? 'La IA no puede aportar edge en este tipo de mercado (deportes, predicciones de palabras, etc.).'
520
+ : 'AΓΊn no se ha generado una seΓ±al para este mercado.'
521
+ footer.append(probWrap, probVal, placeholder)
522
+ }
523
 
 
524
  card.append(cat, q, footer)
525
+
526
+ // Edge row: Mercado vs IA con barra de comparacion
527
+ if (hasSignal && sig.impliedProb != null && sig.fairProb != null) {
528
+ const edgeRow = el('div', 'edge-row')
529
+ const edgePts = sig.edgePoints ?? 0
530
+ const edgeAbs = Math.abs(edgePts)
531
+ const edgeDir = edgePts > 0 ? 'pos' : edgePts < 0 ? 'neg' : 'zero'
532
+
533
+ const impliedSpan = el('span', 'edge-implied')
534
+ impliedSpan.textContent = `Mercado ${Math.round(sig.impliedProb * 100)}%`
535
+
536
+ const sep1 = el('span', 'edge-sep', 'Β·')
537
+ const fairSpan = el('span', `edge-fair text-${cls}`)
538
+ fairSpan.textContent = `IA ${Math.round(sig.fairProb * 100)}%`
539
+
540
+ const sep2 = el('span', 'edge-sep', 'Β·')
541
+ const edgeSpan = el('span', `edge-value edge-${edgeDir}`)
542
+ const sign = edgePts > 0 ? '+' : edgePts < 0 ? 'βˆ’' : ''
543
+ edgeSpan.textContent = `Edge ${sign}${edgeAbs.toFixed(1)}pp`
544
+
545
+ edgeRow.append(impliedSpan, sep1, fairSpan, sep2, edgeSpan)
546
+ card.append(edgeRow)
547
+ }
548
+
549
  card.addEventListener('click', () => selectMarket(card.dataset.market))
550
  return card
551
  }
 
568
 
569
  /* ─── Render signals list ─── */
570
  function renderSignals() {
571
+ const filtered = filterMarkets(state.markets, state.filters)
572
+ renderSignalsFiltered(filtered)
573
+ }
574
+
575
+ function renderSignalsFiltered(marketsToRender) {
576
  const container = document.getElementById('signals-list')
577
  if (!container) return
578
 
579
  if (signalsObserver) { signalsObserver.disconnect(); signalsObserver = null }
580
  container.replaceChildren()
581
 
582
+ if (marketsToRender.length === 0) {
583
+ container.appendChild(emptyState('No hay mercados que coincidan con los filtros'))
584
  return
585
  }
586
 
587
+ marketsToRender.forEach((m) => container.appendChild(makeSignalCard(m)))
 
588
  }
589
 
590
  function appendSignalCards(newMarkets) {
 
662
  const metricConf = el('div', 'metric')
663
  metricConf.append(el('div', 'metric-label', 'Confianza'), confEl)
664
 
665
+ // ── SÍ / NO mini chips in header ──
666
+ const yesMiniPrice = el('div', 'outcome-mini-price yes')
667
+ yesMiniPrice.textContent = formatPrice(m.yesPrice)
668
+ const yesMini = el('div', 'outcome-mini')
669
+ yesMini.append(el('div', 'outcome-mini-label', 'SÍ'), yesMiniPrice)
670
+
671
+ const noMiniPrice = el('div', 'outcome-mini-price no')
672
+ noMiniPrice.textContent = formatPrice(m.noPrice)
673
+ const noMini = el('div', 'outcome-mini')
674
+ noMini.append(el('div', 'outcome-mini-label', 'NO'), noMiniPrice)
675
+
676
  const metrics = el('div', 'detail-metrics')
677
+ metrics.append(yesMini, noMini, el('div', 'metric-sep'), metricDelta, el('div', 'metric-sep'), metricConf)
678
 
679
  const header = el('div', 'detail-header')
680
  header.append(headerLeft, metrics)
681
 
682
+ // ── Outcomes row: AI box + chart ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  const detailChart = el('canvas')
684
  detailChart.id = chartId
685
  const chartContainer = el('div', 'chart-container')
686
  chartContainer.append(el('div', 'chart-label', 'Historial de precios 7d'), detailChart)
687
 
688
  const outcomesRow = el('div', 'outcomes-row')
 
689
 
690
  // ── AI box ──
691
  const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
692
  aiBadge.textContent = `${translateSignal(sig.signal).toUpperCase()} Β· ${Math.round(sig.confidence * 100)}%`
693
+
694
+ const modelBadge = el('span', 'model-badge')
695
+ modelBadge.textContent = sig.modelVersion || 'IA'
696
+
697
+ const aiTitleGroup = el('div', 'ai-title-group')
698
+ aiTitleGroup.append(el('div', 'ai-icon', 'β—ˆ'), el('div', 'ai-label', 'AnΓ‘lisis IA'), modelBadge)
699
+
700
  const aiHeader = el('div', 'flex-between mb-4')
701
+ aiHeader.append(aiTitleGroup, aiBadge)
702
 
703
  // Texto IA construido con nodos DOM β€” ninguna cadena externa toca innerHTML
704
  const aiText = el('div', 'ai-text')
 
709
  aiText.append(' ', strong, ' ', sig.keyRisk)
710
  }
711
 
 
 
712
  const aiBox = el('div', 'ai-box')
713
+ aiBox.append(aiHeader, aiText)
714
+
715
+ outcomesRow.append(aiBox, chartContainer)
716
 
717
  // ── Simulator row ──
718
+ // El backend calcula sizing (Kelly cost-aware con spread). Aqui solo lo pintamos.
719
  const simAmount = el('input', 'sim-input')
720
  simAmount.type = 'number'
721
  simAmount.value = '100'
 
725
  const simYes = el('button', 'sim-btn-yes', 'COMPRAR SÍ β†—')
726
  const simNo = el('button', 'sim-btn-no', 'COMPRAR NO')
727
 
728
+ const noteRow = el('div', 'kelly-note')
729
+ noteRow.textContent = 'Calculando sugerencia…'
730
+
731
  const simRow = el('div', 'sim-row')
732
  simRow.append(
733
+ noteRow,
734
  el('span', 'sim-label', 'Simular posiciΓ³n β†’'),
735
  simAmount,
736
  simYes,
 
741
  simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
742
 
743
  const content = el('div')
744
+ content.append(header, outcomesRow, simRow)
745
+
746
+ // Fetch async: el backend conoce spread + ultima senal + Kelly conservador
747
+ api.getPositionSuggestion(m.id).then((sug) => {
748
+ if (!sug) return
749
+ if (sug.illiquid) {
750
+ simYes.disabled = true
751
+ simNo.disabled = true
752
+ simYes.title = `Mercado ilΓ­quido (spread ${Math.round((m.spread ?? 0) * 100)}Β’).`
753
+ simNo.title = simYes.title
754
+ noteRow.classList.add('kelly-warn')
755
+ }
756
+ if (sug.amountEur > 0) {
757
+ simAmount.value = String(sug.amountEur)
758
+ }
759
+ noteRow.textContent = sug.note || ''
760
+ }).catch(() => {
761
+ noteRow.textContent = ''
762
+ })
763
 
764
  return { content, chartId, sparkYesId, sparkNoId }
765
  }
 
770
  if (!container) return
771
  const m = state.markets.find((x) => x.id === state.activeMarketId)
772
  if (!m) {
773
+ container.replaceChildren()
774
  return
775
  }
776
 
 
784
  const { content, chartId, sparkYesId, sparkNoId } = buildDetailDOM(m, sig)
785
  container.replaceChildren(content)
786
 
787
+ api.getMarketHistory(m.id).then((history) => {
788
+ charts.renderDetailChart(chartId, m.yesPrice, history)
789
+ }).catch(() => {
790
+ charts.renderDetailChart(chartId, m.yesPrice)
791
+ })
792
  charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
793
  charts.renderSparkline(sparkNoId, m.noPrice, 'no')
794
  }
 
833
  // Charts require the canvas to be in the DOM before rendering.
834
  // Scroll after paint so the layout height is settled.
835
  requestAnimationFrame(() => {
836
+ api.getMarketHistory(m.id).then((history) => {
837
+ charts.renderDetailChart(chartId, m.yesPrice, history)
838
+ }).catch(() => {
839
+ charts.renderDetailChart(chartId, m.yesPrice)
840
+ })
841
  charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
842
  charts.renderSparkline(sparkNoId, m.noPrice, 'no')
843
 
 
1019
  /* ─── Carga de datos ─── */
1020
  async function loadMarkets() {
1021
  try {
1022
+ const batch = await api.getMarkets({ limit: 60, offset: 0 })
1023
  state.markets = Array.isArray(batch) ? batch : []
1024
  state.signalsOffset = state.markets.length
1025
+ state.signalsHasMore = state.markets.length === 60
1026
  } catch (e) {
1027
  console.error('Error cargando mercados:', e)
1028
  state.markets = []
 
1035
  if (state.signalsLoading || !state.signalsHasMore) return
1036
  state.signalsLoading = true
1037
  try {
1038
+ const batch = await api.getMarkets({ limit: 40, offset: state.signalsOffset })
1039
  const arr = Array.isArray(batch) ? batch : []
1040
  if (arr.length === 0) {
1041
  state.signalsHasMore = false
 
1044
  } else {
1045
  state.markets.push(...arr)
1046
  state.signalsOffset += arr.length
1047
+ state.signalsHasMore = arr.length === 40
1048
  try {
1049
  const newSigs = await api.getSignalsBatch(arr.map((m) => m.id))
1050
  state.signals.push(...newSigs.map((r) => ({ ...r, marketId: r.marketId })))
1051
  } catch (e) { /* signals are optional */ }
1052
+ populateFilters()
1053
+ applyFilters()
1054
  }
1055
  } catch (e) {
1056
  console.error('Error cargando mΓ‘s mercados:', e)
 
1126
  await loadAlerts()
1127
  await loadStats()
1128
 
1129
+ // Inicializar historial de precios para tracking de trends
1130
+ state.markets.forEach((m) => {
1131
+ if (m.yesPrice != null) {
1132
+ recordPrice(m.id, m.yesPrice)
1133
+ }
1134
+ })
1135
+
1136
+ populateFilters()
1137
  map.init('map-container', state.markets, state.signals, selectMarket)
1138
  simulator.init(state)
1139
 
 
1186
  const authed = await ensureAuth()
1187
  if (authed) {
1188
  await initAppData()
1189
+ initFilters()
1190
  } else {
1191
  openAuthModal()
1192
  }
 
1198
  const m = state.markets.find((x) => x.id === data.marketId)
1199
  if (m) {
1200
  // Solo copia campos numericos conocidos β€” nunca mezcle todo el payload
1201
+ if (typeof data.yesPrice === 'number') {
1202
+ recordPrice(m.id, data.yesPrice)
1203
+ m.yesPrice = data.yesPrice
1204
+ }
1205
  if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
1206
  if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
1207
  if (typeof data.liquidityEur === 'number') m.liquidityEur = data.liquidityEur
frontend/src/charts.js CHANGED
@@ -17,24 +17,42 @@ import { Chart } from 'chart.js/auto'
17
 
18
  let detailChartInstance = null
19
 
20
- export function renderDetailChart(canvasId, currentPrice) {
21
  const ctx = document.getElementById(canvasId)
22
  if (!ctx) return
23
  if (detailChartInstance) detailChartInstance.destroy()
24
 
25
- const base = currentPrice * 100
26
- const pts = Array.from({ length: 8 }, (_, i) => {
27
- const noise = (Math.random() - 0.5) * 8
28
- return Math.max(5, Math.min(95, base - 12 + (i / 7) * 12 + noise))
29
- })
30
- pts[pts.length - 1] = base
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
- const col = base > 50 ? '#22d37a' : base < 40 ? '#f04040' : '#f0a020'
 
33
 
34
  detailChartInstance = new Chart(ctx, {
35
  type: 'line',
36
  data: {
37
- labels: ['7d', '6d', '5d', '4d', '3d', '2d', '1d', 'now'],
38
  datasets: [
39
  {
40
  data: pts,
 
17
 
18
  let detailChartInstance = null
19
 
20
+ export function renderDetailChart(canvasId, currentPrice, history = []) {
21
  const ctx = document.getElementById(canvasId)
22
  if (!ctx) return
23
  if (detailChartInstance) detailChartInstance.destroy()
24
 
25
+ const container = ctx.parentElement
26
+ if (container) {
27
+ const labelEl = container.querySelector('.chart-label')
28
+ const labelH = labelEl ? labelEl.offsetHeight + 6 : 24
29
+ ctx.style.height = `${container.clientHeight - labelH - 28}px`
30
+ }
31
+
32
+ let pts, labels
33
+ if (history.length >= 2) {
34
+ pts = history.map((h) => +(h.p * 100).toFixed(2))
35
+ labels = history.map((h) => {
36
+ const d = new Date(h.t * 1000)
37
+ return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}h`
38
+ })
39
+ } else {
40
+ const base = currentPrice * 100
41
+ pts = Array.from({ length: 8 }, (_, i) => {
42
+ const noise = (Math.random() - 0.5) * 8
43
+ return Math.max(5, Math.min(95, base - 12 + (i / 7) * 12 + noise))
44
+ })
45
+ pts[pts.length - 1] = base
46
+ labels = ['7d', '6d', '5d', '4d', '3d', '2d', '1d', 'ahora']
47
+ }
48
 
49
+ const last = pts[pts.length - 1]
50
+ const col = last > 50 ? '#22d37a' : last < 40 ? '#f04040' : '#f0a020'
51
 
52
  detailChartInstance = new Chart(ctx, {
53
  type: 'line',
54
  data: {
55
+ labels,
56
  datasets: [
57
  {
58
  data: pts,
frontend/src/filters.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Utilidades de filtrado para el dashboard.
3
+ *
4
+ * Expone funciones para extraer opciones unicas de filtros desde mercados.
5
+ */
6
+
7
+ // ── Extractores de opciones de filtro ─────────────────────────────────────────
8
+
9
+ export function extractFilterOptions(markets) {
10
+ const categories = new Set()
11
+
12
+ for (const m of markets) {
13
+ if (m.category) categories.add(m.category)
14
+ }
15
+
16
+ return {
17
+ categories: ['Todas', ...Array.from(categories).sort()],
18
+ }
19
+ }
20
+
21
+ // ── Filtrado ──────────────────────────────────────────────────────────────────
22
+
23
+ export function filterMarkets(markets, { category }) {
24
+ return markets.filter((m) => {
25
+ if (category && category !== 'Todas' && m.category !== category) return false
26
+ return true
27
+ })
28
+ }
frontend/src/map.js CHANGED
@@ -28,17 +28,134 @@ import { getCoordsByCode, detectCountryInText } from './capitals.js'
28
  let mapInstance = null
29
  let bubbles = {} // marketId -> marcador de circulo
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  function getCoords(market) {
32
- // 1. intentar por countryCode ISO2 del mercado
33
  const byCode = getCoordsByCode(market.countryCode)
34
- if (byCode) return byCode
 
 
 
35
 
36
- // 2. fallback: detectar paΓ­s en el texto de la pregunta
37
  const byText = detectCountryInText(market.question)
38
- if (byText) return byText
39
 
40
- // 3. default centrado en África (coordenadas genéricas)
41
- return [20, 0]
42
  }
43
 
44
  function getSignalColor(signal) {
@@ -57,6 +174,7 @@ function getRadius(volumeEur) {
57
  }
58
 
59
  export function init(containerId, markets, signals, onSelect) {
 
60
  const container = document.getElementById(containerId)
61
  if (!container) return
62
 
@@ -142,7 +260,7 @@ export function init(containerId, markets, signals, onSelect) {
142
  })
143
  }
144
 
145
- export function updateBubble(marketId, newPrice) {
146
  const b = bubbles[marketId]
147
  if (!b) return
148
  // Ajusta ligeramente el radio basado en nueva actividad (comportamiento simulado)
@@ -160,3 +278,80 @@ export function highlightMarket(marketId) {
160
  b.circle.openPopup()
161
  }
162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
33
+ // "spread" propio para que multiples markets en el mismo hub se repartan.
34
+ const FINANCIAL_HUBS = [
35
+ // America del Norte
36
+ [40.71, -74.0], // Nueva York
37
+ [37.77, -122.42], // San Francisco
38
+ [41.88, -87.63], // Chicago
39
+ [43.65, -79.38], // Toronto
40
+ [19.43, -99.13], // Ciudad de Mexico
41
+ // America Latina
42
+ [-23.55, -46.63], // Sao Paulo
43
+ [-34.61, -58.38], // Buenos Aires
44
+ [-33.45, -70.66], // Santiago de Chile
45
+ [4.71, -74.07], // Bogota
46
+ [10.49, -66.88], // Caracas
47
+ // Europa
48
+ [51.5, -0.13], // Londres
49
+ [50.11, 8.68], // Frankfurt
50
+ [48.85, 2.35], // Paris
51
+ [40.42, -3.7], // Madrid
52
+ [41.9, 12.5], // Roma
53
+ [47.37, 8.54], // Zurich
54
+ [52.37, 4.9], // Amsterdam
55
+ [55.75, 37.62], // Moscu
56
+ [50.45, 30.52], // Kyiv
57
+ [41.0, 28.98], // Estambul
58
+ // Africa
59
+ [-26.2, 28.05], // Johannesburgo
60
+ [6.45, 3.4], // Lagos
61
+ [30.04, 31.24], // Cairo
62
+ [-1.29, 36.82], // Nairobi
63
+ [33.97, -6.85], // Rabat
64
+ // Oriente Medio
65
+ [25.2, 55.27], // Dubai
66
+ [24.71, 46.68], // Riad
67
+ [32.07, 34.78], // Tel Aviv
68
+ // Asia
69
+ [22.3, 114.17], // Hong Kong
70
+ [35.68, 139.69], // Tokio
71
+ [1.35, 103.82], // Singapur
72
+ [31.23, 121.47], // Shanghai
73
+ [19.08, 72.88], // Mumbai
74
+ [37.57, 126.98], // Seul
75
+ [13.76, 100.5], // Bangkok
76
+ [-6.21, 106.85], // Yakarta
77
+ [25.03, 121.56], // Taipei
78
+ // Oceania
79
+ [-33.87, 151.21], // Sydney
80
+ [-37.81, 144.96], // Melbourne
81
+ [-36.85, 174.76], // Auckland
82
+ ]
83
+
84
+ // Hash determinista para jitter reproducible por marketId
85
+ function hashCode(str) {
86
+ let h = 0
87
+ const s = String(str)
88
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
89
+ return Math.abs(h)
90
+ }
91
+
92
+ // Tamano aproximado de cada pais (radio en grados, lat/lng).
93
+ // Paises grandes β†’ mas jitter para repartir bubbles por su territorio.
94
+ // Default 3.5Β° si no esta listado.
95
+ // Bounding-box approximado de cada pais (semi-eje lat, semi-eje lng en grados).
96
+ // Paises grandes β†’ caja grande para repartir las bubbles por todo el territorio.
97
+ const COUNTRY_JITTER = {
98
+ US: [14, 32], // EEUU: NY (-74) a LA (-118), TX (29) a MN (49)
99
+ RU: [12, 55], // Rusia: cruza 11 husos horarios
100
+ CA: [11, 35],
101
+ CN: [13, 25],
102
+ BR: [13, 18],
103
+ AU: [11, 22],
104
+ IN: [10, 14],
105
+ AR: [14, 10],
106
+ MX: [8, 13],
107
+ KZ: [7, 22],
108
+ DZ: [10, 11],
109
+ SA: [9, 11],
110
+ CD: [9, 9],
111
+ GB: [4, 4],
112
+ DE: [4, 5],
113
+ FR: [5, 5],
114
+ ES: [5, 6],
115
+ IT: [5, 5],
116
+ JP: [6, 8],
117
+ TR: [4, 9],
118
+ IR: [7, 9],
119
+ IL: [1.2, 1.2],
120
+ UA: [5, 9],
121
+ KR: [2.5, 2.5],
122
+ ZA: [6, 8],
123
+ NG: [5, 6],
124
+ EG: [5, 6],
125
+ ID: [6, 22],
126
+ }
127
+
128
+ // Distribucion UNIFORME en un rectangulo (no en elipse polar, que clumpea
129
+ // hacia el centro). Cada market id mapea a un (dx,dy) deterministico que
130
+ // cubre toda la bounding box del pais.
131
+ function jitter(coords, marketId, amount = 4) {
132
+ const h = hashCode(marketId)
133
+ const [latR, lngR] = Array.isArray(amount) ? amount : [amount, amount]
134
+ // Dos canales de 16 bits del hash β†’ coordenadas uniformes en [-1, 1]
135
+ const u = ((h & 0xFFFF) / 0xFFFF) * 2 - 1
136
+ const v = (((h >> 16) & 0xFFFF) / 0xFFFF) * 2 - 1
137
+ return [coords[0] + v * latR, coords[1] + u * lngR]
138
+ }
139
+
140
+ function pickFinancialHub(marketId) {
141
+ const idx = hashCode(marketId) % FINANCIAL_HUBS.length
142
+ return FINANCIAL_HUBS[idx]
143
+ }
144
+
145
  function getCoords(market) {
146
+ // 1. countryCode ISO2 explicito + jitter proporcional al tamano del pais
147
  const byCode = getCoordsByCode(market.countryCode)
148
+ if (byCode) {
149
+ const amount = COUNTRY_JITTER[market.countryCode] || 3.5
150
+ return jitter(byCode, market.id, amount)
151
+ }
152
 
153
+ // 2. heuristica de texto: detectamos el codigo y reaplicamos el lookup
154
  const byText = detectCountryInText(market.question)
155
+ if (byText) return jitter(byText, market.id, 4)
156
 
157
+ // 3. mercado sin pais: distribuir entre hubs financieros globales
158
+ return jitter(pickFinancialHub(market.id), market.id, 3)
159
  }
160
 
161
  function getSignalColor(signal) {
 
174
  }
175
 
176
  export function init(containerId, markets, signals, onSelect) {
177
+ window.__onSelectMarket = onSelect
178
  const container = document.getElementById(containerId)
179
  if (!container) return
180
 
 
260
  })
261
  }
262
 
263
+ export function updateBubble(marketId) {
264
  const b = bubbles[marketId]
265
  if (!b) return
266
  // Ajusta ligeramente el radio basado en nueva actividad (comportamiento simulado)
 
278
  b.circle.openPopup()
279
  }
280
  }
281
+
282
+ export function updateMarkers(markets, signals) {
283
+ if (!mapInstance) return
284
+
285
+ // Limpiar burbujas existentes
286
+ Object.values(bubbles).forEach((b) => {
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) => {
295
+ const sig = signals.find((s) => s.marketId === m.id) || { signal: 'neutral' }
296
+ const color = getSignalColor(sig.signal)
297
+ const coords = getCoords(m)
298
+ const radius = getRadius(m.volumeEur)
299
+
300
+ const circle = L.circleMarker(coords, {
301
+ radius,
302
+ fillColor: color,
303
+ color: color,
304
+ weight: 1.5,
305
+ opacity: 0.6,
306
+ fillOpacity: 0.22,
307
+ }).addTo(mapInstance)
308
+
309
+ const inner = L.circleMarker(coords, {
310
+ radius: Math.max(3, radius * 0.45),
311
+ fillColor: color,
312
+ color: 'transparent',
313
+ fillOpacity: 0.8,
314
+ interactive: false,
315
+ }).addTo(mapInstance)
316
+
317
+ const labelSpan = document.createElement('span')
318
+ labelSpan.className = 'map-label-text'
319
+ labelSpan.textContent = m.countryCode || 'GL'
320
+
321
+ const label = L.marker(coords, {
322
+ icon: L.divIcon({
323
+ className: 'map-label',
324
+ html: labelSpan.outerHTML,
325
+ iconSize: [40, 14],
326
+ iconAnchor: [20, -radius - 4],
327
+ }),
328
+ interactive: false,
329
+ }).addTo(mapInstance)
330
+
331
+ const popup = document.createElement('div')
332
+ popup.className = 'map-popup'
333
+ const popupCat = document.createElement('div')
334
+ popupCat.className = 'map-popup-cat'
335
+ popupCat.textContent = `${m.category || 'General'} Β· ${m.countryCode || 'GL'}`
336
+ const popupQ = document.createElement('div')
337
+ popupQ.className = 'map-popup-q'
338
+ popupQ.textContent = m.question
339
+ const popupPrices = document.createElement('div')
340
+ popupPrices.className = 'map-popup-prices'
341
+ const yesSpan = document.createElement('span')
342
+ yesSpan.className = 'text-green'
343
+ yesSpan.textContent = `SÍ ${Math.round((m.yesPrice || 0) * 100)}’`
344
+ const noSpan = document.createElement('span')
345
+ noSpan.className = 'text-red'
346
+ noSpan.textContent = `NO ${Math.round((m.noPrice || 0) * 100)}Β’`
347
+ popupPrices.append(yesSpan, noSpan)
348
+ popup.append(popupCat, popupQ, popupPrices)
349
+ circle.bindPopup(popup, { closeButton: false, offset: [0, -4] })
350
+
351
+ circle.on('click', () => {
352
+ if (window.__onSelectMarket) window.__onSelectMarket(m.id)
353
+ })
354
+
355
+ bubbles[m.id] = { circle, inner, label, color }
356
+ })
357
+ }
frontend/src/style.css CHANGED
@@ -306,6 +306,47 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
306
  .stat-delta.dn { color: var(--red); }
307
  .stat-delta.neutral { color: var(--text3); }
308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  .topbar-actions {
310
  margin-left: auto;
311
  display: flex;
@@ -367,7 +408,7 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
367
  .dashboard-grid {
368
  display: grid;
369
  grid-template-columns: 1fr 336px;
370
- grid-template-rows: 1fr minmax(280px, 40%);
371
  gap: var(--panel-gap);
372
  height: 100%;
373
  min-height: 0;
@@ -472,11 +513,11 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
472
  display: flex;
473
  align-items: center;
474
  gap: 12px;
 
475
  }
476
 
477
- /* Always pin the toggle to the far right of the map panel header */
478
- #panel-map .panel-header .panel-toggle {
479
- margin-left: auto;
480
  }
481
 
482
  /* Panel mapa */
@@ -587,17 +628,97 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
587
  .sig-bull { background: var(--green3); color: var(--green); border: 0.5px solid var(--green2); }
588
  .sig-bear { background: var(--red3); color: var(--red); border: 0.5px solid var(--red2); }
589
  .sig-neut { background: var(--bg4); color: var(--text2); border: 0.5px solid var(--border2); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
 
591
  /* Panel detalle */
592
  .detail-panel {
593
  grid-row: 2 / 3;
594
  grid-column: 1 / 2;
595
- max-height: 100%;
596
  overflow: hidden;
597
  }
598
 
599
  .detail-panel .panel-body {
600
- overflow-y: auto;
 
 
 
 
 
 
 
 
 
601
  }
602
 
603
  .detail-header {
@@ -664,7 +785,8 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
664
  .outcomes-row {
665
  display: flex;
666
  gap: 16px;
667
- margin-bottom: 12px;
 
668
  }
669
 
670
  .outcome-card {
@@ -705,15 +827,11 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
705
  border: 0.5px solid var(--border);
706
  border-radius: var(--radius-sm);
707
  padding: 14px;
708
- min-height: 120px;
709
- height: 160px;
710
- max-height: 160px;
711
  overflow: hidden;
 
712
  }
713
 
714
- .chart-container canvas {
715
- max-height: 130px;
716
- }
717
 
718
  .chart-label {
719
  font-size: 0.875rem;
@@ -722,15 +840,55 @@ h6 { font-size: var(--fs-h6); line-height: 1.4; font-weight: 500; }
722
  margin-bottom: 6px;
723
  }
724
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
  .ai-box {
 
726
  background: var(--bg3);
727
  border: 0.5px solid var(--blue2);
728
  border-radius: var(--radius-sm);
729
  padding: 14px;
730
  display: flex;
731
- gap: 14px;
732
- align-items: flex-start;
733
- margin-bottom: 10px;
 
734
  }
735
 
736
  .ai-icon {
@@ -1458,6 +1616,58 @@ td {
1458
  font-size: 0.875rem;
1459
  }
1460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1461
  /* ─── Responsive ─── */
1462
  @media (max-width: 1024px) {
1463
  * {
 
306
  .stat-delta.dn { color: var(--red); }
307
  .stat-delta.neutral { color: var(--text3); }
308
 
309
+ .topbar-filters {
310
+ display: flex;
311
+ align-items: center;
312
+ gap: 10px;
313
+ flex-shrink: 0;
314
+ }
315
+
316
+ .filter-select {
317
+ background: var(--bg3);
318
+ border: 0.5px solid var(--border2);
319
+ border-radius: var(--radius-sm);
320
+ padding: 7px 12px;
321
+ color: var(--text);
322
+ font-size: 0.875rem;
323
+ font-family: var(--font-mono);
324
+ outline: none;
325
+ cursor: pointer;
326
+ min-width: 140px;
327
+ max-width: 180px;
328
+ appearance: none;
329
+ -webkit-appearance: none;
330
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b90a0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
331
+ background-repeat: no-repeat;
332
+ background-position: right 10px center;
333
+ padding-right: 30px;
334
+ transition: border-color 0.15s;
335
+ }
336
+
337
+ .filter-select:hover {
338
+ border-color: var(--border2);
339
+ }
340
+
341
+ .filter-select:focus {
342
+ border-color: var(--blue2);
343
+ }
344
+
345
+ .filter-select option {
346
+ background: var(--bg2);
347
+ color: var(--text);
348
+ }
349
+
350
  .topbar-actions {
351
  margin-left: auto;
352
  display: flex;
 
408
  .dashboard-grid {
409
  display: grid;
410
  grid-template-columns: 1fr 336px;
411
+ grid-template-rows: minmax(120px, 59%) 1fr;
412
  gap: var(--panel-gap);
413
  height: 100%;
414
  min-height: 0;
 
513
  display: flex;
514
  align-items: center;
515
  gap: 12px;
516
+ margin-left: auto;
517
  }
518
 
519
+ #panel-map .panel-header {
520
+ justify-content: flex-start;
 
521
  }
522
 
523
  /* Panel mapa */
 
628
  .sig-bull { background: var(--green3); color: var(--green); border: 0.5px solid var(--green2); }
629
  .sig-bear { background: var(--red3); color: var(--red); border: 0.5px solid var(--red2); }
630
  .sig-neut { background: var(--bg4); color: var(--text2); border: 0.5px solid var(--border2); }
631
+ .sig-none {
632
+ background: transparent;
633
+ color: var(--text3, #6e7681);
634
+ border: 0.5px dashed var(--border2);
635
+ font-size: 0.72rem;
636
+ letter-spacing: 0.06em;
637
+ font-weight: 500;
638
+ }
639
+
640
+ .model-badge {
641
+ font-size: 0.65rem;
642
+ font-weight: 500;
643
+ color: var(--text3, #6e7681);
644
+ background: var(--bg4);
645
+ border: 0.5px solid var(--border2);
646
+ border-radius: 4px;
647
+ padding: 1px 5px;
648
+ margin-left: 6px;
649
+ font-family: var(--font-mono);
650
+ letter-spacing: 0.04em;
651
+ text-transform: uppercase;
652
+ flex-shrink: 0;
653
+ }
654
+
655
+ /* Spread badge inline en categoria */
656
+ .spread-badge {
657
+ margin-left: 6px;
658
+ font-size: 0.7rem;
659
+ color: var(--text3, #6e7681);
660
+ font-family: var(--font-mono);
661
+ }
662
+ .spread-badge.illiquid {
663
+ color: var(--red, #f04040);
664
+ font-weight: 600;
665
+ }
666
+
667
+ /* Fila Edge: Mercado Β· IA Β· Edge */
668
+ .edge-row {
669
+ display: flex;
670
+ align-items: center;
671
+ gap: 6px;
672
+ margin-top: 6px;
673
+ padding-top: 6px;
674
+ border-top: 0.5px solid var(--border2, #1f242b);
675
+ font-size: 0.72rem;
676
+ font-family: var(--font-mono);
677
+ letter-spacing: 0.02em;
678
+ }
679
+ .edge-implied { color: var(--text2, #9aa4b2); }
680
+ .edge-fair { font-weight: 600; }
681
+ .edge-sep { color: var(--text3, #4a5260); opacity: 0.6; }
682
+ .edge-value { font-weight: 700; padding: 1px 5px; border-radius: 3px; }
683
+ .edge-pos { color: var(--green, #22d37a); background: var(--green3, rgba(34,211,122,0.08)); }
684
+ .edge-neg { color: var(--red, #f04040); background: var(--red3, rgba(240,64,64,0.08)); }
685
+ .edge-zero { color: var(--text3, #6e7681); }
686
+
687
+ /* Sugerencia de tamano (Kelly) bajo el simulador */
688
+ .kelly-note {
689
+ flex: 1;
690
+ font-size: 0.78rem;
691
+ color: var(--text2, #9aa4b2);
692
+ font-family: var(--font-mono);
693
+ padding: 4px 8px;
694
+ border-left: 2px solid var(--border2, #1f242b);
695
+ background: rgba(255,255,255,0.02);
696
+ border-radius: 0 4px 4px 0;
697
+ }
698
+ .kelly-warn {
699
+ color: var(--red, #f04040);
700
+ border-left-color: var(--red, #f04040);
701
+ background: rgba(240,64,64,0.05);
702
+ }
703
 
704
  /* Panel detalle */
705
  .detail-panel {
706
  grid-row: 2 / 3;
707
  grid-column: 1 / 2;
 
708
  overflow: hidden;
709
  }
710
 
711
  .detail-panel .panel-body {
712
+ overflow: hidden;
713
+ }
714
+
715
+ /* The anonymous content wrapper built by buildDetailDOM */
716
+ .detail-panel .panel-body > div {
717
+ height: 100%;
718
+ display: flex;
719
+ flex-direction: column;
720
+ gap: 10px;
721
+ min-height: 0;
722
  }
723
 
724
  .detail-header {
 
785
  .outcomes-row {
786
  display: flex;
787
  gap: 16px;
788
+ flex: 1;
789
+ min-height: 0;
790
  }
791
 
792
  .outcome-card {
 
827
  border: 0.5px solid var(--border);
828
  border-radius: var(--radius-sm);
829
  padding: 14px;
830
+ min-height: 80px;
 
 
831
  overflow: hidden;
832
+ align-self: stretch;
833
  }
834
 
 
 
 
835
 
836
  .chart-label {
837
  font-size: 0.875rem;
 
840
  margin-bottom: 6px;
841
  }
842
 
843
+ /* SÍ / NO mini chips in the detail header */
844
+ .outcome-mini {
845
+ display: flex;
846
+ flex-direction: column;
847
+ align-items: center;
848
+ gap: 2px;
849
+ padding: 0 10px;
850
+ }
851
+
852
+ .outcome-mini-label {
853
+ font-size: 0.72rem;
854
+ font-family: var(--font-mono);
855
+ color: var(--text3);
856
+ text-transform: uppercase;
857
+ letter-spacing: 0.06em;
858
+ }
859
+
860
+ .outcome-mini-price {
861
+ font-size: 1rem;
862
+ font-weight: 700;
863
+ font-family: var(--font-mono);
864
+ }
865
+
866
+ .outcome-mini-price.yes { color: var(--green); }
867
+ .outcome-mini-price.no { color: var(--red); }
868
+
869
+ .ai-title-group {
870
+ display: flex;
871
+ align-items: center;
872
+ gap: 8px;
873
+ }
874
+
875
+ .ai-title-group .ai-icon {
876
+ width: 20px;
877
+ height: 20px;
878
+ font-size: 0.75rem;
879
+ }
880
+
881
  .ai-box {
882
+ flex: 1;
883
  background: var(--bg3);
884
  border: 0.5px solid var(--blue2);
885
  border-radius: var(--radius-sm);
886
  padding: 14px;
887
  display: flex;
888
+ flex-direction: column;
889
+ gap: 8px;
890
+ overflow: hidden;
891
+ min-width: 0;
892
  }
893
 
894
  .ai-icon {
 
1616
  font-size: 0.875rem;
1617
  }
1618
 
1619
+ /* ─── Trend Select ─── */
1620
+ #filter-trend {
1621
+ min-width: 170px;
1622
+ background: var(--bg3);
1623
+ border: 0.5px solid var(--amber2);
1624
+ color: var(--amber);
1625
+ }
1626
+
1627
+ #filter-trend:focus {
1628
+ border-color: var(--amber);
1629
+ }
1630
+
1631
+ #filter-trend option {
1632
+ background: var(--bg2);
1633
+ color: var(--text);
1634
+ }
1635
+
1636
+ /* ─── Trend Badges ─── */
1637
+ .trend-badge {
1638
+ font-size: 0.75rem;
1639
+ font-weight: 600;
1640
+ padding: 2px 6px;
1641
+ border-radius: 4px;
1642
+ font-family: var(--font-mono);
1643
+ letter-spacing: 0.04em;
1644
+ margin-left: 8px;
1645
+ }
1646
+
1647
+ .trend-hot {
1648
+ background: rgba(240, 160, 32, 0.15);
1649
+ color: var(--amber);
1650
+ border: 0.5px solid var(--amber2);
1651
+ }
1652
+
1653
+ .trend-bull {
1654
+ background: var(--green3);
1655
+ color: var(--green);
1656
+ border: 0.5px solid var(--green2);
1657
+ }
1658
+
1659
+ .trend-bear {
1660
+ background: var(--red3);
1661
+ color: var(--red);
1662
+ border: 0.5px solid var(--red2);
1663
+ }
1664
+
1665
+ .trend-volatile {
1666
+ background: rgba(74, 158, 255, 0.15);
1667
+ color: var(--blue);
1668
+ border: 0.5px solid var(--blue2);
1669
+ }
1670
+
1671
  /* ─── Responsive ─── */
1672
  @media (max-width: 1024px) {
1673
  * {