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 +11 -0
- CLAUDE.md +133 -0
- README.md +72 -6
- backend/prisma/schema.prisma +8 -0
- backend/src/finnhub/finnhub.service.js +422 -69
- backend/src/markets/markets.controller.js +6 -0
- backend/src/markets/markets.repository.js +76 -1
- backend/src/markets/markets.routes.js +1 -0
- backend/src/markets/markets.service.js +22 -0
- backend/src/markets/markets.validators.js +3 -2
- backend/src/markets/polymarket.client.js +193 -10
- backend/src/middlewares/rateLimitLogin.js +3 -1
- backend/src/positions/kelly.js +73 -3
- backend/src/positions/positions.controller.js +6 -0
- backend/src/positions/positions.routes.js +3 -0
- backend/src/positions/positions.service.js +24 -1
- backend/src/scheduler.js +12 -3
- backend/src/signals/aiPipeline.js +120 -28
- backend/src/signals/signals.repository.js +2 -2
- backend/src/signals/signals.service.js +9 -2
- backend/src/utils/coingecko.client.js +100 -0
- frontend/dist/assets/index-B7Y-b3uS.js +0 -0
- frontend/dist/assets/index-D_WoXxRV.css +1 -0
- frontend/dist/assets/index-pjCx9cLl.js +0 -0
- frontend/dist/assets/index-xtYPhhTl.css +0 -1
- frontend/dist/index.html +189 -31
- frontend/index.html +14 -0
- frontend/src/api.js +13 -0
- frontend/src/app.js +290 -47
- frontend/src/charts.js +27 -9
- frontend/src/filters.js +28 -0
- frontend/src/map.js +202 -7
- frontend/src/style.css +226 -16
.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 |
-
|
|
|
|
|
|
|
| 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
|
| 204 |
-
| generateSignals | Cada 5 min | Genera senales IA para
|
| 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
|
| 218 |
-
- **Detalle de mercado:** sparklines, historial
|
| 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 |
-
* -
|
| 6 |
-
*
|
| 7 |
-
*
|
| 8 |
-
* -
|
| 9 |
-
* β agrega noticias de multiples simbolos para el pipeline de IA.
|
| 10 |
*
|
| 11 |
-
*
|
| 12 |
-
*
|
| 13 |
-
*
|
| 14 |
-
*
|
| 15 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
/**
|
| 27 |
-
*
|
| 28 |
-
*
|
| 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 |
-
*
|
| 73 |
*
|
| 74 |
-
*
|
| 75 |
-
*
|
|
|
|
|
|
|
| 76 |
*
|
| 77 |
-
* @param {Object
|
| 78 |
-
* @
|
| 79 |
-
* @returns {Object[]} Noticias filtradas por relevancia.
|
| 80 |
*/
|
| 81 |
-
export function
|
| 82 |
-
|
| 83 |
-
if (keywords.length === 0) return news;
|
| 84 |
|
| 85 |
-
const
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
});
|
|
|
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
/**
|
| 93 |
-
* Obtiene noticias generales de mercado
|
| 94 |
-
*
|
| 95 |
-
* @param {number} [daysBack=7]
|
| 96 |
-
* @returns {Promise<Object[]>}
|
| 97 |
*/
|
| 98 |
-
export async function
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
|
| 110 |
/**
|
| 111 |
-
*
|
| 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
|
| 118 |
-
* @param {string
|
| 119 |
-
* @
|
| 120 |
-
*
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 131 |
-
|
| 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(
|
| 141 |
-
|
| 142 |
} catch (err) {
|
| 143 |
-
logger.warn({ err: err.message, symbol
|
| 144 |
-
return [];
|
| 145 |
}
|
| 146 |
-
}
|
| 147 |
-
|
| 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(
|
| 16 |
offset: z.coerce.number().int().min(0).default(0),
|
| 17 |
-
|
|
|
|
| 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
|
|
|
|
| 20 |
const USD_TO_EUR = 0.93;
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
|
| 192 |
-
const
|
| 193 |
const question = raw.question || '';
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return {
|
| 196 |
id: String(raw.id),
|
| 197 |
question,
|
| 198 |
-
category
|
| 199 |
-
countryCode: inferCountryCode(question,
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
| 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
|
| 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 |
-
? `
|
| 57 |
-
: 'No
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
return [
|
| 59 |
-
`You are a
|
| 60 |
``,
|
| 61 |
`Market: "${market.question}"`,
|
| 62 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
newsSection,
|
| 64 |
``,
|
| 65 |
-
`
|
| 66 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 241 |
let headlines = [];
|
| 242 |
try {
|
| 243 |
-
const
|
| 244 |
-
const relevant = filterNewsByRelevance(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 9 |
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
| 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="
|
| 52 |
-
<
|
| 53 |
-
|
| 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="
|
| 66 |
-
<
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) ||
|
| 325 |
-
const
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 382 |
-
container.appendChild(emptyState('No hay mercados
|
| 383 |
return
|
| 384 |
}
|
| 385 |
|
| 386 |
-
|
| 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(
|
| 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(
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 793 |
state.markets = Array.isArray(batch) ? batch : []
|
| 794 |
state.signalsOffset = state.markets.length
|
| 795 |
-
state.signalsHasMore = state.markets.length ===
|
| 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:
|
| 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 ===
|
| 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 |
-
|
|
|
|
| 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')
|
|
|
|
|
|
|
|
|
|
| 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
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
const
|
|
|
|
| 33 |
|
| 34 |
detailChartInstance = new Chart(ctx, {
|
| 35 |
type: 'line',
|
| 36 |
data: {
|
| 37 |
-
labels
|
| 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.
|
| 33 |
const byCode = getCoordsByCode(market.countryCode)
|
| 34 |
-
if (byCode)
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
// 2.
|
| 37 |
const byText = detectCountryInText(market.question)
|
| 38 |
-
if (byText) return byText
|
| 39 |
|
| 40 |
-
// 3.
|
| 41 |
-
return
|
| 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
|
| 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:
|
| 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 |
-
|
| 478 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 |
-
|
| 732 |
-
|
| 733 |
-
|
|
|
|
| 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 |
* {
|