blackmistcode commited on
Commit
71b8eb2
·
verified ·
1 Parent(s): 5160d3e

Add files using upload-large-folder tool

Browse files
.claude/settings.local.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(curl -s \"https://gamma-api.polymarket.com/tags?limit=500&offset=0\")",
5
+ "Bash(python3 -c ' *)",
6
+ "Bash(curl -s \"https://gamma-api.polymarket.com/tags?limit=20&is_carousel=true\")",
7
+ "Bash(curl -s \"https://gamma-api.polymarket.com/tags?limit=100&is_carousel=true\")",
8
+ "Bash(curl -s \"https://gamma-api.polymarket.com/events?active=true&closed=false&limit=5&order=volume24hr&ascending=false\")",
9
+ "Bash(curl -s 'https://gamma-api.polymarket.com/tags?slug=__TRACKED_VAR__&limit=1')"
10
+ ]
11
+ }
12
+ }
.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Variables de entorno — PolySignal
2
+ #
3
+ # Copiar este archivo a .env y rellenar con tus claves.
4
+ # Nunca commitear el archivo .env real (debe estar en .gitignore).
5
+ # Ubicacion recomendada: en la raiz del proyecto (al lado de docker-compose.yml).
6
+
7
+ # HuggingFace
8
+ HF_TOKEN= # HuggingFace Pro API key (Inference API)
9
+ HF_SPACE_MODERNFINBERT_URL=blackmistcode/polysignal-modernfinbert
10
+ HF_SPACE_QWEN_URL=blackmistcode/polysignal-qwen3-8b
11
+
12
+ # Fallbacks y datos
13
+ OPENROUTER_API_KEY= # Fallback LLM si HF esta saturado
14
+ FINNHUB_API_KEY= # Noticias financieras (finnhub.io)
15
+
16
+ # Alertas
17
+ TELEGRAM_BOT_TOKEN= # Bot de alertas (@BotFather)
18
+
19
+ # Base de datos y auth
20
+ DATABASE_URL=file:./backend/prisma/polysignal.db
21
+ JWT_SECRET=supersecreto-demasiado-largo-para-la-hackathon-2026
22
+
23
+ # Servidor
24
+ PORT=7860 # HuggingFace Spaces requiere 7860
25
+ NODE_ENV=production
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
+ ```
backend/.env.example ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Variables de entorno del backend PolySignal.
2
+ # Copiar este archivo a backend/.env y rellenar los valores.
3
+ # El archivo .env real NO debe commitearse (asegurar entrada en .gitignore).
4
+
5
+ NODE_ENV=development
6
+ PORT=7860
7
+
8
+ # SQLite path relativo al archivo schema.prisma (backend/prisma/).
9
+ DATABASE_URL=file:./polysignal.db
10
+
11
+ # Auth — generar con: openssl rand -hex 48
12
+ JWT_SECRET=replace-with-64-plus-random-chars
13
+ JWT_EXPIRES_IN=1h
14
+ BCRYPT_ROUNDS=10
15
+
16
+ # CORS del frontend Vite en desarrollo.
17
+ CORS_ORIGIN=http://localhost:5173
18
+
19
+ # Logger pino (trace | debug | info | warn | error | fatal).
20
+ LOG_LEVEL=info
backend/docs/ALERTS.md ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ALERTS.md — Módulo de alertas
2
+
3
+ Historial de alertas de precio disparadas por el job `processAlerts`. Las alertas se crean automáticamente cuando `yesPrice >= alertThreshold` en la watchlist del usuario.
4
+
5
+ **Todos los endpoints requieren `Authorization: Bearer <token>`.**
6
+
7
+ ---
8
+
9
+ ## Endpoints
10
+
11
+ ### `GET /api/v1/alerts`
12
+
13
+ Lista el historial de alertas del usuario, paginado y ordenado por `sentAt` DESC.
14
+
15
+ **Query params**
16
+
17
+ | Param | Tipo | Default | Descripción |
18
+ |---|---|---|---|
19
+ | `limit` | int (1-100) | `20` | Máximo de resultados |
20
+ | `offset` | int | `0` | Paginación por offset |
21
+
22
+ **Respuesta `200`**
23
+
24
+ ```json
25
+ {
26
+ "ok": true,
27
+ "data": [
28
+ {
29
+ "id": 4,
30
+ "userId": 1,
31
+ "marketId": "559677",
32
+ "type": "price_threshold",
33
+ "message": "<b>Price Alert</b>\nWill Hillary Clinton win the 2028 Democratic presidential nomination?\nYES: 0.8% ≥ threshold 0.1%",
34
+ "sentAt": "2026-05-16T09:38:00.033Z",
35
+ "market": {
36
+ "id": "559677",
37
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?"
38
+ }
39
+ }
40
+ ]
41
+ }
42
+ ```
43
+
44
+ **Campos**
45
+
46
+ | Campo | Tipo | Descripción |
47
+ |---|---|---|
48
+ | `id` | int | ID de la alerta |
49
+ | `userId` | int | Usuario que la recibió |
50
+ | `marketId` | string | Mercado que la disparó |
51
+ | `type` | string | Tipo de alerta (`price_threshold`) |
52
+ | `message` | string | Mensaje enviado (formato HTML para Telegram) |
53
+ | `sentAt` | ISO 8601 | Timestamp de envío |
54
+ | `market.question` | string | Pregunta del mercado (para mostrar en UI) |
55
+
56
+ ---
57
+
58
+ ## Flujo de creación
59
+
60
+ Las alertas **no se crean desde el frontend** — solo se leen. El flujo de creación es:
61
+
62
+ 1. `POST /api/v1/watchlist` con `alertThreshold` → guarda umbral en DB.
63
+ 2. Job `processAlerts` (cada minuto): evalúa `yesPrice >= alertThreshold` para cada watchlist entry con threshold definido.
64
+ 3. Si se cumple y no hay alerta reciente (< 5 min): crea `Alert` + envía Telegram + emite `price_alert` por socket.
65
+
66
+ ---
67
+
68
+ ## Socket — evento `price_alert`
69
+
70
+ **Nombre del evento:** `price_alert`
71
+
72
+ **Payload**
73
+
74
+ ```json
75
+ {
76
+ "marketId": "559677",
77
+ "type": "price_threshold",
78
+ "message": "<b>Price Alert</b>\n..."
79
+ }
80
+ ```
81
+
82
+ **Uso en el frontend**
83
+
84
+ ```js
85
+ socket.on('price_alert', ({ marketId, message }) => {
86
+ // mostrar notificación toast
87
+ });
88
+ ```
89
+
90
+ ---
91
+
92
+ ## Integración Telegram
93
+
94
+ Para recibir alertas vía Telegram:
95
+
96
+ 1. Crear un bot en [@BotFather](https://t.me/BotFather) y obtener el `TELEGRAM_BOT_TOKEN`.
97
+ 2. El usuario debe iniciar conversación con el bot y obtener su `chatId`.
98
+ 3. Configurar `telegramChatId` en el registro `User` (actualmente solo vía `prisma studio` o seed).
99
+ 4. Añadir `TELEGRAM_BOT_TOKEN` al `.env`.
100
+
101
+ Si `TELEGRAM_BOT_TOKEN` no está configurado o el usuario no tiene `telegramChatId`, el envío se omite silenciosamente (la `Alert` se crea igualmente en DB).
102
+
103
+ ---
104
+
105
+ ## Ejemplos `curl`
106
+
107
+ ```bash
108
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
109
+ -H 'Content-Type: application/json' \
110
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' | jq -r '.data.token')
111
+
112
+ # Listar alertas (últimas 10)
113
+ curl -s -H "Authorization: Bearer $TOKEN" \
114
+ "http://localhost:7860/api/v1/alerts?limit=10" | jq
115
+
116
+ # Segunda página
117
+ curl -s -H "Authorization: Bearer $TOKEN" \
118
+ "http://localhost:7860/api/v1/alerts?limit=10&offset=10" | jq
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Códigos de error
124
+
125
+ | HTTP | Código | Cuándo |
126
+ |---|---|---|
127
+ | `400` | `VALIDATION_ERROR` | Params inválidos (limit > 100) |
128
+ | `401` | `UNAUTHORIZED` | Sin token o token inválido |
129
+ | `500` | `INTERNAL` | Error inesperado |
backend/docs/API.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API.md — Referencia general PolySignal Backend
2
+
3
+ Base URL (dev): `http://localhost:7860`
4
+ Base URL (prod, HF Spaces): misma URL que el frontend (mismo origen).
5
+
6
+ ---
7
+
8
+ ## Arranque rápido
9
+
10
+ ```bash
11
+ cd backend/
12
+ npm install
13
+ npm run db:migrate # solo primera vez
14
+ npm run db:seed # crea usuarios de prueba
15
+ npm run dev # http://localhost:7860
16
+ ```
17
+
18
+ Usuarios de prueba: `admin@polysignal.test / Admin123!` y `user@polysignal.test / User123!`
19
+
20
+ ---
21
+
22
+ ## Autenticación
23
+
24
+ El API usa JWT Bearer. Para obtener un token:
25
+
26
+ ```bash
27
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
28
+ -H 'Content-Type: application/json' \
29
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' | jq -r '.data.token')
30
+ ```
31
+
32
+ Enviarlo en cada request protegido:
33
+
34
+ ```
35
+ Authorization: Bearer <token>
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Endpoints
41
+
42
+ ### Públicos (sin auth)
43
+
44
+ | Método | Ruta | Descripción | Doc |
45
+ |---|---|---|---|
46
+ | `GET` | `/api/v1/health` | Sanity check | [AUTH.md](AUTH.md) |
47
+ | `POST` | `/api/v1/auth/login` | Login → JWT | [AUTH.md](AUTH.md) |
48
+ | `GET` | `/api/v1/markets` | Lista mercados paginada | [MARKETS.md](MARKETS.md) |
49
+ | `GET` | `/api/v1/markets/:id` | Detalle de mercado | [MARKETS.md](MARKETS.md) |
50
+ | `GET` | `/api/v1/markets/:id/signal` | Señal AI del mercado | [SIGNALS.md](SIGNALS.md) |
51
+
52
+ ### Protegidos (requieren `Authorization: Bearer <token>`)
53
+
54
+ | Método | Ruta | Descripción | Doc |
55
+ |---|---|---|---|
56
+ | `GET` | `/api/v1/auth/me` | Perfil del usuario | [AUTH.md](AUTH.md) |
57
+ | `POST` | `/api/v1/positions` | Abrir posición | [POSITIONS.md](POSITIONS.md) |
58
+ | `GET` | `/api/v1/positions` | Listar posiciones | [POSITIONS.md](POSITIONS.md) |
59
+ | `DELETE` | `/api/v1/positions/:id` | Cerrar posición | [POSITIONS.md](POSITIONS.md) |
60
+ | `POST` | `/api/v1/watchlist` | Añadir a watchlist | [WATCHLIST.md](WATCHLIST.md) |
61
+ | `GET` | `/api/v1/watchlist` | Listar watchlist | [WATCHLIST.md](WATCHLIST.md) |
62
+ | `DELETE` | `/api/v1/watchlist/:marketId` | Eliminar de watchlist | [WATCHLIST.md](WATCHLIST.md) |
63
+ | `GET` | `/api/v1/alerts` | Historial de alertas | [ALERTS.md](ALERTS.md) |
64
+
65
+ ---
66
+
67
+ ## Contrato de respuesta
68
+
69
+ Todas las respuestas siguen el mismo envelope:
70
+
71
+ ```json
72
+ // éxito
73
+ { "ok": true, "data": <payload>, "meta": { ...opcional } }
74
+
75
+ // error
76
+ { "ok": false, "error": { "code": "ERROR_CODE", "message": "...", "details": [...] } }
77
+ ```
78
+
79
+ Códigos HTTP: `200` lectura · `201` creación · `204` borrado · `400` validación · `401` no autenticado · `404` no encontrado · `409` conflicto · `429` rate limit · `500` server error.
80
+
81
+ ---
82
+
83
+ ## Rate limiting
84
+
85
+ - **Global:** 200 req / 15 min / IP
86
+ - **`POST /auth/login`:** 5 intentos / 15 min / IP
87
+
88
+ Headers de respuesta: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`.
89
+
90
+ ---
91
+
92
+ ## WebSocket (Socket.io)
93
+
94
+ Conectar al mismo host que el backend:
95
+
96
+ ```js
97
+ import { io } from 'socket.io-client';
98
+ const socket = io('http://localhost:7860');
99
+ ```
100
+
101
+ | Evento | Frecuencia | Descripción | Doc |
102
+ |---|---|---|---|
103
+ | `market_update` | cada 30s | Precio actualizado de un mercado | [MARKETS.md](MARKETS.md) |
104
+ | `ai_signal` | cada 5 min | Nueva señal AI generada | [SIGNALS.md](SIGNALS.md) |
105
+ | `price_alert` | cuando se dispara | Alerta de precio threshold | [ALERTS.md](ALERTS.md) |
106
+
107
+ ---
108
+
109
+ ## Importar en Insomnia / Postman
110
+
111
+ Ver [`insomnia-collection.json`](insomnia-collection.json) — export v4 listo para importar en Insomnia.
112
+
113
+ Pasos:
114
+ 1. Abrir Insomnia → Import → File
115
+ 2. Seleccionar `backend/docs/insomnia-collection.json`
116
+ 3. Configurar variable de entorno `base_url = http://localhost:7860`
117
+ 4. Ejecutar `POST /auth/login` y copiar el token a la variable `token`
118
+
119
+ ---
120
+
121
+ ## Variables de entorno completas
122
+
123
+ Ver [`backend/.env.example`](../.env.example) para la plantilla completa.
124
+
125
+ | Variable | Obligatoria | Descripción |
126
+ |---|---|---|
127
+ | `PORT` | Sí | Puerto del servidor (7860 para HF Spaces) |
128
+ | `DATABASE_URL` | Sí | Path SQLite: `file:./polysignal.db` |
129
+ | `JWT_SECRET` | Sí | Mínimo 32 chars |
130
+ | `JWT_EXPIRES_IN` | No | Default `1h` |
131
+ | `BCRYPT_ROUNDS` | No | Default `10` |
132
+ | `CORS_ORIGIN` | No | Default `http://localhost:5173` |
133
+ | `LOG_LEVEL` | No | Default `info` |
134
+ | `HF_TOKEN` | No | HuggingFace (señales AI reales) |
135
+ | `OPENROUTER_API_KEY` | No | Fallback LLM |
136
+ | `FINNHUB_API_KEY` | No | Noticias para señales |
137
+ | `TELEGRAM_BOT_TOKEN` | No | Alertas Telegram |
backend/docs/AUTH.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Auth — Guía de uso
2
+
3
+ Login con email + password. El backend emite un JWT (HS256, default 1h) que viaja en `Authorization: Bearer <token>`. No hay registro público en esta fase: los usuarios se crean vía el seeder. **No hay roles** — `requireAuth` solo valida que el usuario esté logueado y activo, para que pueda mantener sus preferencias.
4
+
5
+ ## 1. Variables de entorno
6
+
7
+ Copiar `backend/.env.example` a `backend/.env` y rellenar:
8
+
9
+ | Variable | Default | Notas |
10
+ |---|---|---|
11
+ | `NODE_ENV` | `development` | `development` \| `test` \| `production` |
12
+ | `PORT` | `7860` | Puerto del backend (HF Spaces requiere 7860) |
13
+ | `DATABASE_URL` | `file:./polysignal.db` | Path SQLite **relativo a `prisma/schema.prisma`** |
14
+ | `JWT_SECRET` | — (obligatorio) | Mínimo 32 chars. Generar con `openssl rand -hex 48` |
15
+ | `JWT_EXPIRES_IN` | `1h` | Formato `jsonwebtoken` (`1h`, `15m`, `7d`, ...) |
16
+ | `BCRYPT_ROUNDS` | `10` | Entre 4 y 15 |
17
+ | `CORS_ORIGIN` | `http://localhost:5173` | Origen del frontend en dev |
18
+ | `LOG_LEVEL` | `info` | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
19
+
20
+ > Si el `.env` falta una variable obligatoria o un valor es inválido, el backend imprime el error y aborta el arranque (validación con Zod en `src/config.js`).
21
+
22
+ ## 2. Primer arranque
23
+
24
+ Desde `backend/`:
25
+
26
+ ```bash
27
+ npm install # instala deps
28
+ npm run db:migrate -- --name init_auth # solo la primera vez (ya hecho)
29
+ npm run db:seed # crea los 2 usuarios de prueba
30
+ npm run dev # arranca en http://localhost:7860
31
+ ```
32
+
33
+ Para inspeccionar la DB en una UI:
34
+
35
+ ```bash
36
+ npm run db:studio
37
+ ```
38
+
39
+ ## 3. Usuarios de prueba
40
+
41
+ Sembrados por `prisma/seed.js` (idempotente, se puede re-ejecutar):
42
+
43
+ | Email | Password |
44
+ |---|---|
45
+ | `admin@polysignal.test` | `Admin123!` |
46
+ | `user@polysignal.test` | `User123!` |
47
+
48
+ ## 4. Endpoints
49
+
50
+ ### `GET /api/v1/health`
51
+
52
+ Sanity check. Respuesta:
53
+
54
+ ```json
55
+ { "ok": true, "data": { "status": "up" } }
56
+ ```
57
+
58
+ ### `POST /api/v1/auth/login`
59
+
60
+ Body:
61
+
62
+ ```json
63
+ { "email": "admin@polysignal.test", "password": "Admin123!" }
64
+ ```
65
+
66
+ Respuesta `200`:
67
+
68
+ ```json
69
+ {
70
+ "ok": true,
71
+ "data": {
72
+ "token": "eyJhbGciOiJIUzI1NiJ9...",
73
+ "user": { "id": 1, "email": "admin@polysignal.test" }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Errores:
79
+
80
+ | HTTP | code | cuándo |
81
+ |---|---|---|
82
+ | `400` | `VALIDATION_ERROR` | Email inválido o password < 8 chars |
83
+ | `401` | `INVALID_CREDENTIALS` | Email no existe, usuario desactivado, o password incorrecta |
84
+ | `429` | `TOO_MANY_REQUESTS` | Más de 5 intentos en 15 min desde la misma IP |
85
+
86
+ ### `GET /api/v1/auth/me`
87
+
88
+ Requiere header `Authorization: Bearer <token>`. Respuesta `200`:
89
+
90
+ ```json
91
+ {
92
+ "ok": true,
93
+ "data": {
94
+ "user": {
95
+ "id": 1,
96
+ "email": "admin@polysignal.test",
97
+ "isActive": true,
98
+ "createdAt": "2026-05-16T07:11:43.000Z"
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Errores:
105
+
106
+ | HTTP | code | cuándo |
107
+ |---|---|---|
108
+ | `401` | `UNAUTHORIZED` | Sin header, token mal formado, expirado, manipulado, o usuario desactivado |
109
+
110
+ ## 5. Ejemplos con `curl`
111
+
112
+ ```bash
113
+ # 1) Login y guardar token en variable
114
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
115
+ -H 'Content-Type: application/json' \
116
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' \
117
+ | jq -r '.data.token')
118
+
119
+ # 2) Llamar a /me con el token
120
+ curl -s http://localhost:7860/api/v1/auth/me \
121
+ -H "Authorization: Bearer $TOKEN" | jq
122
+ ```
123
+
124
+ ## 6. Login desde el frontend (referencia)
125
+
126
+ El JWT es opaco para el front: basta con guardarlo (sessionStorage o estado en memoria) y enviarlo en cada request protegido.
127
+
128
+ ```js
129
+ const res = await fetch('/api/v1/auth/login', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ email, password }),
133
+ });
134
+ const json = await res.json();
135
+ if (!json.ok) throw new Error(json.error.code);
136
+ const { token, user } = json.data;
137
+ // guardar token y user
138
+
139
+ // requests autenticados
140
+ fetch('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
141
+ ```
142
+
143
+ > En dev, Vite proxea `/api/*` al backend (`localhost:7860`); no hace falta CORS si va por el proxy, pero ya está configurado por si el front llama directo.
144
+
145
+ ## 7. Cómo proteger nuevos endpoints
146
+
147
+ ```js
148
+ import { requireAuth } from '../middlewares/requireAuth.js';
149
+
150
+ router.get('/positions', requireAuth, controller.list);
151
+ ```
152
+
153
+ Dentro del controller, `req.user` ya está disponible con `{ id, email, isActive, createdAt }` — suficiente para filtrar datos del usuario logueado (ej. sus preferencias, posiciones, watchlist).
154
+
155
+ ## 8. Rotar `JWT_SECRET`
156
+
157
+ Genera uno nuevo y reemplaza el valor de `JWT_SECRET` en `backend/.env`:
158
+
159
+ ```bash
160
+ openssl rand -hex 48
161
+ ```
162
+
163
+ Al cambiar el secreto, todos los tokens emitidos previamente quedan invalidados (los clientes deben volver a hacer login).
164
+
165
+ ## 9. Notas técnicas
166
+
167
+ - **Hashing:** `bcryptjs` (puro JS, sin compilación nativa — más portable a HF Spaces) con coste `BCRYPT_ROUNDS` (default 10).
168
+ - **JWT:** algoritmo `HS256`, claim `sub = user.id`, `email`, `iat`, `exp`.
169
+ - **Rate limit en `/auth/login`:** `express-rate-limit`, 5 intentos / 15 min / IP.
170
+ - **Validación de body:** zod schema en `src/auth/auth.validators.js`.
171
+ - **Errores formateados:** todas las respuestas siguen `{ ok, data }` o `{ ok:false, error:{ code, message, details? } }` vía `src/utils/apiResponse.js` y el middleware `errorHandler`.
172
+ - **Prisma:** singleton en `src/utils/prisma.js` para no abrir conexiones de más con `node --watch`.
backend/docs/FINNHUB.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FINNHUB.md — Modulo de noticias financieras
2
+
3
+ Fuente de titulares financieros para el pipeline de IA. Consulta la API REST de Finnhub (free tier) y proporciona noticias filtradas por relevancia al mercado analizado.
4
+
5
+ **No expone endpoints REST publicos.** Es un modulo interno consumido unicamente por `signals/aiPipeline.js` durante la generacion de senales (cada 5 min).
6
+
7
+ ---
8
+
9
+ ## Variables de entorno
10
+
11
+ | Variable | Obligatoria | Descripcion |
12
+ |---|---|---|
13
+ | `FINNHUB_API_KEY` | No | API key de Finnhub (finnhub.io). Sin ella el modulo devuelve array vacio y el pipeline usa rule-based. |
14
+
15
+ ---
16
+
17
+ ## API interna
18
+
19
+ ### `getMarketNews(category, limit)`
20
+
21
+ **Archivo:** `src/finnhub/finnhub.client.js`
22
+
23
+ Obtiene noticias generales del mercado por categoria.
24
+
25
+ **Parametros**
26
+
27
+ | Param | Tipo | Default | Descripcion |
28
+ |---|---|---|---|
29
+ | `category` | string | `"general"` | Categoria: `general`, `forex`, `crypto`, `merger` |
30
+ | `limit` | int | `50` | Maximo de noticias a devolver |
31
+
32
+ **Retorno:** `Promise<Object[]>` — array de noticias normalizadas.
33
+
34
+ **Noticia normalizada:**
35
+
36
+ ```json
37
+ {
38
+ "id": 12345,
39
+ "headline": "Fed signals potential rate cut...",
40
+ "summary": "The Federal Reserve hinted at...",
41
+ "url": "https://example.com/news/12345",
42
+ "source": "Reuters",
43
+ "related": "AAPL",
44
+ "datetime": "2026-05-15T14:30:00.000Z"
45
+ }
46
+ ```
47
+
48
+ ---
49
+
50
+ ### `getCompanyNews(symbol, daysBack)`
51
+
52
+ **Archivo:** `src/finnhub/finnhub.client.js`
53
+
54
+ Obtiene noticias de una empresa especifica en un rango de fechas.
55
+
56
+ **Parametros**
57
+
58
+ | Param | Tipo | Default | Descripcion |
59
+ |---|---|---|---|
60
+ | `symbol` | string | — | Simbolo bursatil (ej. `AAPL`, `TSLA`, `BTC`) |
61
+ | `daysBack` | int | `7` | Dias hacia atras desde hoy |
62
+
63
+ **Retorno:** `Promise<Object[]>` — noticias normalizadas de la empresa.
64
+
65
+ ---
66
+
67
+ ### `fetchFinancialNews(daysBack)`
68
+
69
+ **Archivo:** `src/finnhub/finnhub.service.js`
70
+
71
+ Agrega noticias de multiples categorias (`general`, `forex`, `crypto`) para el pipeline de IA.
72
+
73
+ **Parametros**
74
+
75
+ | Param | Tipo | Default | Descripcion |
76
+ |---|---|---|---|
77
+ | `daysBack` | int | `7` | No aplica directamente; se usa el rango por defecto de Finnhub |
78
+
79
+ **Retorno:** `Promise<Object[]>` — hasta 90 noticias agregadas (50 general + 20 forex + 20 crypto).
80
+
81
+ ---
82
+
83
+ ### `filterNewsByRelevance(news, question)`
84
+
85
+ **Archivo:** `src/finnhub/finnhub.service.js`
86
+
87
+ Filtra noticias por keywords extraidas de la pregunta del mercado. Elimina stop words en ingles y espanol; matching case-insensitive.
88
+
89
+ **Parametros**
90
+
91
+ | Param | Tipo | Descripcion |
92
+ |---|---|---|
93
+ | `news` | Object[] | Array de noticias normalizadas |
94
+ | `question` | string | Pregta del mercado (ej. "Will Bitcoin reach $100k?") |
95
+
96
+ **Retorno:** `Object[]` — noticias que contienen al menos una keyword en `headline` o `summary`.
97
+
98
+ ---
99
+
100
+ ### `fetchHeadlinesForPipeline({ symbols, daysBack, limitPerSymbol })`
101
+
102
+ **Archivo:** `src/finnhub/finnhub.service.js`
103
+
104
+ Obtiene noticias de multiples simbolos en paralelo, truncando a maximo 5 simbolos para respetar el rate limit.
105
+
106
+ **Parametros**
107
+
108
+ | Param | Tipo | Default | Descripcion |
109
+ |---|---|---|---|
110
+ | `symbols` | string[] | `[]` | Lista de simbolos bursatiles |
111
+ | `daysBack` | int | `7` | Dias hacia atras |
112
+ | `limitPerSymbol` | int | `20` | Maximo de noticias por simbolo |
113
+
114
+ **Retorno:** `Promise<Object[]>` — noticias agregadas con campo `symbol` anadido.
115
+
116
+ **Limitacion:** si `symbols.length > 5`, se trunca a 5 y se emite warning en logs.
117
+
118
+ ---
119
+
120
+ ## Pipeline de noticias dentro de la generacion de senales
121
+
122
+ ```
123
+ scheduler.js (cada 5 min)
124
+
125
+ aiPipeline.run(market)
126
+ ↓ Paso 1: Obtener noticias
127
+ fetchFinancialNews() → general + forex + crypto
128
+ ↓ Paso 2: Filtrar por relevancia
129
+ filterNewsByRelevance(news, market.question)
130
+ ↓ Paso 3: Filtrar por sentimiento (FinBERT)
131
+ filterWithFinBERT(headlines)
132
+ ↓ Paso 4: Generar senal (Qwen3-8B / OpenRouter / rule-based)
133
+ ```
134
+
135
+ **Nota:** si Finnhub no esta configurado o falla, los pasos 1-2 devuelven array vacio y el pipeline continua con rule-based usando el precio del mercado.
136
+
137
+ ---
138
+
139
+ ## Rate limiter y restricciones
140
+
141
+ | Restriccion | Implementacion |
142
+ |---|---|
143
+ | **Free tier: 60 llamadas/min** | Rate limiter en memoria en `finnhub.client.js`. Si se excede, devuelve `[]`. |
144
+ | **Sin API key** | `ensureApiKey()` devuelve `false`; todas las funciones retornan `[]` sin lanzar error. |
145
+ | **Sin endpoints REST** | El modulo es puramente interno; no se expone via HTTP. |
146
+ | **Limite de 5 simbolos** | `fetchHeadlinesForPipeline` trunca `symbols` a 5 para no saturar el rate limit. |
147
+ | **Errores de red** | Capturados internamente; se loguean como `warn` y se retorna `[]`. |
148
+
149
+ **Calculo de uso tipico:**
150
+ - `fetchFinancialNews()` = 3 llamadas (general + forex + crypto)
151
+ - Top 20 mercados cada 5 min = 20 x 3 = 60 llamadas/5min (en el limite exacto del free tier)
152
+
153
+ ---
154
+
155
+ ## Ejemplo de uso interno
156
+
157
+ ```js
158
+ // Desde aiPipeline.js
159
+ import { fetchFinancialNews, filterNewsByRelevance } from '../finnhub/finnhub.service.js';
160
+
161
+ async function run(market) {
162
+ // 1. Obtener noticias
163
+ const allNews = await fetchFinancialNews(30);
164
+
165
+ // 2. Filtrar por relevancia respecto al mercado
166
+ const relevant = filterNewsByRelevance(allNews, market.question);
167
+
168
+ // 3. Continuar con FinBERT + Qwen3-8B...
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ## Logs esperados
175
+
176
+ ```
177
+ # Sin API key configurada
178
+ [DEBUG] Finnhub API key not configured, returning empty array
179
+
180
+ # Rate limit excedido
181
+ [WARN] Finnhub rate limit exceeded (60 calls/min), returning empty array
182
+
183
+ # Pipeline continua sin noticias
184
+ [WARN] news fetch failed, continuing without news
185
+ ```
186
+
187
+ ---
188
+
189
+ ## Archivos del modulo
190
+
191
+ ```
192
+ backend/src/finnhub/
193
+ ├── finnhub.client.js # Cliente HTTP + rate limiter
194
+ └── finnhub.service.js # Logica de negocio y filtrado
195
+ ```
196
+
197
+ ---
198
+
199
+ *Ultima actualizacion: mayo 2026*
backend/docs/MARKETS.md ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MARKETS.md — Módulo de mercados
2
+
3
+ Referencia para el frontend: contrato HTTP, mapping de datos de Polymarket, evento socket y errores.
4
+
5
+ ---
6
+
7
+ ## Variables de entorno requeridas
8
+
9
+ ```
10
+ DATABASE_URL=file:./backend/prisma/polysignal.db
11
+ PORT=7860
12
+ ```
13
+
14
+ No se necesita clave de API para Polymarket Gamma (pública).
15
+
16
+ ---
17
+
18
+ ## Endpoints
19
+
20
+ ### `GET /api/v1/markets`
21
+
22
+ Lista paginada de mercados activos sincronizados desde Polymarket. **No requiere autenticación.**
23
+
24
+ **Query params**
25
+
26
+ | Param | Tipo | Default | Descripción |
27
+ |---|---|---|---|
28
+ | `limit` | int (1-100) | `20` | Máximo de resultados |
29
+ | `offset` | int | `0` | Paginación por offset |
30
+ | `category` | string | — | Filtro: `politics` \| `crypto` \| `economics` \| `sports` |
31
+ | `status` | string | `active` | Filtro: `active` \| `closed` \| `resolved` |
32
+
33
+ **Respuesta `200`**
34
+
35
+ ```json
36
+ {
37
+ "ok": true,
38
+ "data": [
39
+ {
40
+ "id": "559677",
41
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
42
+ "category": null,
43
+ "countryCode": null,
44
+ "yesPrice": 0.0075,
45
+ "noPrice": 0.9925,
46
+ "volumeEur": 38608906.44,
47
+ "liquidityEur": 2301398.64,
48
+ "status": "active",
49
+ "closesAt": "2028-11-07T00:00:00.000Z",
50
+ "lastSynced": "2026-05-16T09:38:30.204Z"
51
+ }
52
+ ],
53
+ "meta": {
54
+ "total": 100,
55
+ "limit": 1,
56
+ "offset": 0
57
+ }
58
+ }
59
+ ```
60
+
61
+ > `category` y `countryCode` pueden ser `null` si Polymarket no los proporciona.
62
+
63
+ ---
64
+
65
+ ### `GET /api/v1/markets/:id`
66
+
67
+ Detalle de un mercado por su ID de Polymarket. **No requiere autenticación.**
68
+
69
+ **Params**
70
+
71
+ | Param | Tipo | Descripción |
72
+ |---|---|---|
73
+ | `id` | string | ID numérico de Polymarket (ej. `559677`) |
74
+
75
+ **Respuesta `200`**
76
+
77
+ ```json
78
+ {
79
+ "ok": true,
80
+ "data": {
81
+ "id": "559677",
82
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
83
+ "category": null,
84
+ "countryCode": null,
85
+ "yesPrice": 0.0075,
86
+ "noPrice": 0.9925,
87
+ "volumeEur": 38608906.44,
88
+ "liquidityEur": 2301398.64,
89
+ "status": "active",
90
+ "closesAt": "2028-11-07T00:00:00.000Z",
91
+ "lastSynced": "2026-05-16T09:38:30.204Z"
92
+ }
93
+ }
94
+ ```
95
+
96
+ **Respuesta `404`**
97
+
98
+ ```json
99
+ {
100
+ "ok": false,
101
+ "error": { "code": "NOT_FOUND", "message": "Market not found" }
102
+ }
103
+ ```
104
+
105
+ ---
106
+
107
+ ### `GET /api/v1/markets/:id/signal`
108
+
109
+ Señal AI más reciente para un mercado. Ver [SIGNALS.md](SIGNALS.md) para el contrato completo.
110
+
111
+ ---
112
+
113
+ ## Mapping Polymarket Gamma API → `Market`
114
+
115
+ URL de origen: `https://gamma-api.polymarket.com/markets?active=true&closed=false&limit=100`
116
+
117
+ | Campo Gamma API | Campo `Market` | Transformación |
118
+ |---|---|---|
119
+ | `id` | `id` | String (ID numérico de Polymarket) |
120
+ | `question` | `question` | Directo |
121
+ | `category` | `category` | Minúsculas; `null` si Gamma no lo envía |
122
+ | — | `countryCode` | `null` por defecto (no proporcionado por Gamma) |
123
+ | `outcomePrices[0]` | `yesPrice` | `parseFloat`; rango 0.0–1.0 |
124
+ | `outcomePrices[1]` | `noPrice` | `parseFloat`; rango 0.0–1.0 |
125
+ | `volume` | `volumeEur` | `parseFloat(volume) * 0.93` (USD→EUR tasa fija) |
126
+ | `liquidity` | `liquidityEur` | Igual que `volumeEur` |
127
+ | `active` + `closed` + `archived` | `status` | `active=true → "active"`, `closed=true → "closed"`, `archived=true → "resolved"` |
128
+ | `endDateIso` | `closesAt` | `new Date(endDateIso)` |
129
+ | — | `lastSynced` | `new Date()` en el momento del upsert |
130
+
131
+ La sincronización usa `prisma.market.upsert({ where: { id }, ... })` para evitar duplicados.
132
+
133
+ ---
134
+
135
+ ## Socket — evento `market_update`
136
+
137
+ Emitido por `src/socket/broadcaster.js` cada 30 s (tras `syncMarkets`).
138
+
139
+ **Nombre del evento:** `market_update`
140
+
141
+ **Payload**
142
+
143
+ ```json
144
+ {
145
+ "marketId": "0x1a2b...",
146
+ "yesPrice": 0.63,
147
+ "noPrice": 0.37,
148
+ "volumeEur": 125000.00
149
+ }
150
+ ```
151
+
152
+ **Uso en el frontend (Socket.io client)**
153
+
154
+ ```js
155
+ import { io } from 'socket.io-client';
156
+
157
+ const socket = io('http://localhost:7860');
158
+ socket.on('market_update', ({ marketId, yesPrice, noPrice }) => {
159
+ // actualizar estado local del mercado
160
+ });
161
+ ```
162
+
163
+ En producción (HF Spaces) el frontend y backend comparten origen → usar `io()` sin URL.
164
+
165
+ ---
166
+
167
+ ## Ejemplos `curl`
168
+
169
+ ```bash
170
+ # Listar mercados (primeros 5)
171
+ curl "http://localhost:7860/api/v1/markets?limit=5"
172
+
173
+ # Filtrar por estado
174
+ curl "http://localhost:7860/api/v1/markets?status=active&limit=10"
175
+
176
+ # Detalle de un mercado
177
+ curl "http://localhost:7860/api/v1/markets/559677"
178
+
179
+ # Señal AI del mercado
180
+ curl "http://localhost:7860/api/v1/markets/559677/signal"
181
+ ```
182
+
183
+ ---
184
+
185
+ ## Códigos de error relevantes
186
+
187
+ | HTTP | Código | Cuándo |
188
+ |---|---|---|
189
+ | `400` | `VALIDATION_ERROR` | Parámetro inválido (ej. `limit` > 100) |
190
+ | `404` | `NOT_FOUND` | ID de mercado no existe en DB |
191
+ | `500` | `INTERNAL` | Error inesperado del servidor |
192
+
193
+ Los endpoints de markets **no requieren autenticación** — son datos públicos.
backend/docs/POSITIONS.md ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # POSITIONS.md — Módulo de posiciones
2
+
3
+ Simulador de capital virtual. Las posiciones no generan órdenes reales en Polymarket — son un tracking local de apuestas simuladas en euros.
4
+
5
+ **Todos los endpoints requieren `Authorization: Bearer <token>`.**
6
+
7
+ ---
8
+
9
+ ## Endpoints
10
+
11
+ ### `POST /api/v1/positions`
12
+
13
+ Abre una posición nueva en un mercado activo.
14
+
15
+ **Body**
16
+
17
+ ```json
18
+ {
19
+ "marketId": "559677",
20
+ "outcome": "YES",
21
+ "amountEur": 100
22
+ }
23
+ ```
24
+
25
+ | Campo | Tipo | Descripción |
26
+ |---|---|---|
27
+ | `marketId` | string | ID del mercado (debe existir y estar `active`) |
28
+ | `outcome` | `"YES"` \| `"NO"` | Lado de la apuesta |
29
+ | `amountEur` | float > 0 | Importe a invertir en euros |
30
+
31
+ **Respuesta `201`**
32
+
33
+ ```json
34
+ {
35
+ "ok": true,
36
+ "data": {
37
+ "id": 1,
38
+ "userId": 1,
39
+ "marketId": "559677",
40
+ "outcome": "YES",
41
+ "amountEur": 100,
42
+ "entryPrice": 0.0075,
43
+ "currentPrice": 0.0075,
44
+ "pnl": 0,
45
+ "kellyFraction": 0.25,
46
+ "status": "open",
47
+ "openedAt": "2026-05-16T09:14:55.750Z",
48
+ "closedAt": null,
49
+ "market": {
50
+ "id": "559677",
51
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
52
+ "yesPrice": 0.0075,
53
+ "noPrice": 0.9925,
54
+ "status": "active"
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ **Campos de respuesta relevantes**
61
+
62
+ | Campo | Descripción |
63
+ |---|---|
64
+ | `entryPrice` | Precio en el momento de abrir (`yesPrice` o `noPrice` según `outcome`) |
65
+ | `currentPrice` | Precio actual (actualizado por `updatePositionsPnL` cada 30s) |
66
+ | `pnl` | Profit & Loss en EUR (negativo = pérdida) |
67
+ | `kellyFraction` | Fracción de Kelly calculada (capada al 0.25) |
68
+ | `status` | `"open"` \| `"closed"` |
69
+
70
+ ---
71
+
72
+ ### `GET /api/v1/positions`
73
+
74
+ Lista todas las posiciones del usuario autenticado (abiertas y cerradas).
75
+
76
+ **Query params**
77
+
78
+ | Param | Tipo | Default | Descripción |
79
+ |---|---|---|---|
80
+ | `limit` | int (1-100) | `20` | Máximo de resultados |
81
+ | `offset` | int | `0` | Paginación por offset |
82
+
83
+ **Respuesta `200`**
84
+
85
+ ```json
86
+ {
87
+ "ok": true,
88
+ "data": [
89
+ {
90
+ "id": 1,
91
+ "userId": 1,
92
+ "marketId": "559677",
93
+ "outcome": "YES",
94
+ "amountEur": 100,
95
+ "entryPrice": 0.0075,
96
+ "currentPrice": 0.0075,
97
+ "pnl": 0,
98
+ "kellyFraction": 0.25,
99
+ "status": "closed",
100
+ "openedAt": "2026-05-16T09:14:55.750Z",
101
+ "closedAt": "2026-05-16T09:15:04.155Z",
102
+ "market": {
103
+ "id": "559677",
104
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
105
+ "yesPrice": 0.0075,
106
+ "noPrice": 0.9925,
107
+ "status": "active"
108
+ }
109
+ }
110
+ ]
111
+ }
112
+ ```
113
+
114
+ ---
115
+
116
+ ### `DELETE /api/v1/positions/:id`
117
+
118
+ Cierra una posición abierta. Calcula el P&L final con el precio actual.
119
+
120
+ **Params**
121
+
122
+ | Param | Tipo | Descripción |
123
+ |---|---|---|
124
+ | `id` | int | ID de la posición |
125
+
126
+ **Respuesta `200`**
127
+
128
+ ```json
129
+ {
130
+ "ok": true,
131
+ "data": {
132
+ "id": 1,
133
+ "status": "closed",
134
+ "closedAt": "2026-05-16T09:15:04.155Z",
135
+ "pnl": -99.25
136
+ }
137
+ }
138
+ ```
139
+
140
+ **Errores**
141
+
142
+ | HTTP | Código | Cuándo |
143
+ |---|---|---|
144
+ | `404` | `NOT_FOUND` | Posición no existe o no pertenece al usuario |
145
+ | `409` | `CONFLICT` | La posición ya está cerrada |
146
+
147
+ ---
148
+
149
+ ## Criterio de Kelly
150
+
151
+ `kellyFraction = (pWin - pLoss) / pWin` capado al 25% y nunca negativo.
152
+
153
+ - `pWin` = precio del outcome elegido (`yesPrice` o `noPrice`)
154
+ - `pLoss` = `1 - pWin`
155
+
156
+ El resultado es informativo — el usuario decide cuánto invertir.
157
+
158
+ ---
159
+
160
+ ## Socket — actualización de P&L
161
+
162
+ El job `updatePositionsPnL` recalcula `currentPrice` y `pnl` de todas las posiciones abiertas cada 30s. No emite evento de socket propio; el frontend puede re-fetch `GET /positions` tras recibir `market_update`.
163
+
164
+ ---
165
+
166
+ ## Ejemplos `curl`
167
+
168
+ ```bash
169
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
170
+ -H 'Content-Type: application/json' \
171
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' | jq -r '.data.token')
172
+
173
+ # Abrir posición
174
+ curl -s -X POST http://localhost:7860/api/v1/positions \
175
+ -H "Authorization: Bearer $TOKEN" \
176
+ -H "Content-Type: application/json" \
177
+ -d '{"marketId":"559677","outcome":"YES","amountEur":50}' | jq
178
+
179
+ # Listar posiciones
180
+ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:7860/api/v1/positions | jq
181
+
182
+ # Cerrar posición (id=1)
183
+ curl -s -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:7860/api/v1/positions/1 | jq
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Códigos de error
189
+
190
+ | HTTP | Código | Cuándo |
191
+ |---|---|---|
192
+ | `400` | `VALIDATION_ERROR` | Body inválido (outcome no es YES/NO, amountEur <= 0) |
193
+ | `401` | `UNAUTHORIZED` | Sin token o token inválido |
194
+ | `404` | `NOT_FOUND` | Mercado o posición no existe |
195
+ | `409` | `CONFLICT` | Mercado no activo o posición ya cerrada |
196
+ | `500` | `INTERNAL` | Error inesperado |
backend/docs/SIGNALS.md ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SIGNALS.md — Módulo de señales AI
2
+
3
+ Referencia para el frontend: señales generadas por la pipeline FinBERT + Qwen3-8B sobre mercados de Polymarket.
4
+
5
+ ---
6
+
7
+ ## Variables de entorno (opcionales)
8
+
9
+ | Variable | Descripción |
10
+ |---|---|
11
+ | `HF_TOKEN` | HuggingFace Pro token — FinBERT + Qwen3-8B |
12
+ | `OPENROUTER_API_KEY` | Fallback LLM si HuggingFace está saturado |
13
+ | `FINNHUB_API_KEY` | Noticias financieras para contexto |
14
+
15
+ Sin ninguna clave, el backend usa una señal **rule-based** derivada de los precios del mercado.
16
+
17
+ ---
18
+
19
+ ## Endpoints
20
+
21
+ ### `GET /api/v1/markets/:id/signal`
22
+
23
+ Señal AI más reciente para el mercado. **No requiere autenticación.**
24
+
25
+ **Params**
26
+
27
+ | Param | Tipo | Descripción |
28
+ |---|---|---|
29
+ | `id` | string | ID numérico de Polymarket (ej. `559677`) |
30
+
31
+ **Respuesta `200`**
32
+
33
+ ```json
34
+ {
35
+ "ok": true,
36
+ "data": {
37
+ "id": 81,
38
+ "marketId": "559677",
39
+ "signal": "bearish",
40
+ "confidence": 0.9,
41
+ "summary": "Market strongly favors NO at 99% probability. Downside momentum dominates.",
42
+ "keyRisk": "Unexpected positive developments could reverse this rapidly.",
43
+ "newsCount": 0,
44
+ "modelVersion": "Qwen3-8B",
45
+ "generatedAt": "2026-05-16T09:35:00.110Z"
46
+ }
47
+ }
48
+ ```
49
+
50
+ **Campos**
51
+
52
+ | Campo | Tipo | Descripción |
53
+ |---|---|---|
54
+ | `id` | int | ID de la señal en DB |
55
+ | `marketId` | string | ID del mercado |
56
+ | `signal` | `"bullish"` \| `"bearish"` \| `"neutral"` | Dirección recomendada |
57
+ | `confidence` | float (0–1) | Nivel de confianza del modelo |
58
+ | `summary` | string | Resumen del análisis |
59
+ | `keyRisk` | string \| null | Principal riesgo identificado |
60
+ | `newsCount` | int | Noticias procesadas por Finnhub |
61
+ | `modelVersion` | string | Modelo que generó la señal (`Qwen3-8B`, `OpenRouter`, `rule-based`) |
62
+ | `generatedAt` | ISO 8601 | Timestamp de generación |
63
+
64
+ **Respuesta `404`**
65
+
66
+ ```json
67
+ {
68
+ "ok": false,
69
+ "error": { "code": "NOT_FOUND", "message": "No signal found for this market" }
70
+ }
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Pipeline de generación
76
+
77
+ 1. **Finnhub** → noticias relacionadas por keywords del `question`
78
+ 2. **FinBERT** (HuggingFace) → filtro de sentimiento; descarta noticias irrelevantes
79
+ 3. **Qwen3-8B** (HuggingFace) → genera señal JSON `{ signal, confidence, summary, keyRisk }`
80
+ 4. **OpenRouter** → fallback si HF está saturado (HTTP 503)
81
+ 5. **Rule-based** → fallback final: `yesPrice > 0.6 → bullish`, `yesPrice < 0.4 → bearish`, else `neutral`
82
+
83
+ La señal se persiste en `AISignal` y se emite por socket (`ai_signal`).
84
+
85
+ ---
86
+
87
+ ## Socket — evento `ai_signal`
88
+
89
+ Emitido por `src/socket/broadcaster.js` cada 5 min (tras `generateSignals`).
90
+
91
+ **Nombre del evento:** `ai_signal`
92
+
93
+ **Payload**
94
+
95
+ ```json
96
+ {
97
+ "marketId": "559677",
98
+ "signal": "bearish",
99
+ "confidence": 0.9,
100
+ "summary": "Market strongly favors NO at 99% probability."
101
+ }
102
+ ```
103
+
104
+ **Uso en el frontend**
105
+
106
+ ```js
107
+ socket.on('ai_signal', ({ marketId, signal, confidence }) => {
108
+ // actualizar badge de señal en la tarjeta del mercado
109
+ });
110
+ ```
111
+
112
+ ---
113
+
114
+ ## Ejemplos `curl`
115
+
116
+ ```bash
117
+ # Señal más reciente de un mercado
118
+ curl "http://localhost:7860/api/v1/markets/559677/signal"
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Códigos de error
124
+
125
+ | HTTP | Código | Cuándo |
126
+ |---|---|---|
127
+ | `404` | `NOT_FOUND` | No hay señal generada aún para ese mercado |
128
+ | `500` | `INTERNAL` | Error inesperado del servidor |
backend/docs/WATCHLIST.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WATCHLIST.md — Módulo de watchlist
2
+
3
+ Mercados favoritos del usuario con umbral de alerta opcional. Cuando `yesPrice >= alertThreshold`, el job `processAlerts` crea una `Alert` y envía notificación por Telegram.
4
+
5
+ **Todos los endpoints requieren `Authorization: Bearer <token>`.**
6
+
7
+ ---
8
+
9
+ ## Endpoints
10
+
11
+ ### `POST /api/v1/watchlist`
12
+
13
+ Añade un mercado a la watchlist del usuario.
14
+
15
+ **Body**
16
+
17
+ ```json
18
+ {
19
+ "marketId": "559677",
20
+ "alertThreshold": 0.75
21
+ }
22
+ ```
23
+
24
+ | Campo | Tipo | Descripción |
25
+ |---|---|---|
26
+ | `marketId` | string | ID del mercado (debe existir en DB) |
27
+ | `alertThreshold` | float (0–1) \| null | Precio YES que dispara la alerta; `null` para no alertar |
28
+
29
+ **Respuesta `201`**
30
+
31
+ ```json
32
+ {
33
+ "ok": true,
34
+ "data": {
35
+ "id": 2,
36
+ "userId": 1,
37
+ "marketId": "559677",
38
+ "alertThreshold": 0.75,
39
+ "createdAt": "2026-05-16T09:21:41.606Z",
40
+ "market": {
41
+ "id": "559677",
42
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
43
+ "yesPrice": 0.0075,
44
+ "noPrice": 0.9925,
45
+ "status": "active"
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ **Respuesta `409`** — mercado ya está en la watchlist del usuario:
52
+
53
+ ```json
54
+ {
55
+ "ok": false,
56
+ "error": { "code": "CONFLICT", "message": "Market already in watchlist" }
57
+ }
58
+ ```
59
+
60
+ ---
61
+
62
+ ### `GET /api/v1/watchlist`
63
+
64
+ Lista todos los mercados en la watchlist del usuario con datos de mercado embebidos.
65
+
66
+ **Respuesta `200`**
67
+
68
+ ```json
69
+ {
70
+ "ok": true,
71
+ "data": [
72
+ {
73
+ "id": 2,
74
+ "userId": 1,
75
+ "marketId": "559677",
76
+ "alertThreshold": 0.001,
77
+ "createdAt": "2026-05-16T09:21:41.606Z",
78
+ "market": {
79
+ "id": "559677",
80
+ "question": "Will Hillary Clinton win the 2028 Democratic presidential nomination?",
81
+ "yesPrice": 0.0075,
82
+ "noPrice": 0.9925,
83
+ "status": "active"
84
+ }
85
+ }
86
+ ]
87
+ }
88
+ ```
89
+
90
+ ---
91
+
92
+ ### `DELETE /api/v1/watchlist/:marketId`
93
+
94
+ Elimina un mercado de la watchlist del usuario.
95
+
96
+ **Params**
97
+
98
+ | Param | Tipo | Descripción |
99
+ |---|---|---|
100
+ | `marketId` | string | ID del mercado |
101
+
102
+ **Respuesta `204`** — sin body.
103
+
104
+ **Respuesta `404`**
105
+
106
+ ```json
107
+ {
108
+ "ok": false,
109
+ "error": { "code": "NOT_FOUND", "message": "Watchlist entry not found" }
110
+ }
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Lógica de alertas
116
+
117
+ El job `processAlerts` (cron `* * * * *`) evalúa cada entrada de watchlist con `alertThreshold` definido:
118
+
119
+ - Si `market.yesPrice >= alertThreshold` → crea `Alert` + envía Telegram + emite `price_alert` por socket.
120
+ - Deduplicación: no se crea una segunda alerta si ya existe una para el mismo mercado en los últimos 5 min.
121
+
122
+ Para recibir alertas por Telegram, el usuario debe tener `telegramChatId` configurado en su perfil (campo opcional en `User`).
123
+
124
+ ---
125
+
126
+ ## Ejemplos `curl`
127
+
128
+ ```bash
129
+ TOKEN=$(curl -s -X POST http://localhost:7860/api/v1/auth/login \
130
+ -H 'Content-Type: application/json' \
131
+ -d '{"email":"admin@polysignal.test","password":"Admin123!"}' | jq -r '.data.token')
132
+
133
+ # Añadir a watchlist con umbral 75%
134
+ curl -s -X POST http://localhost:7860/api/v1/watchlist \
135
+ -H "Authorization: Bearer $TOKEN" \
136
+ -H "Content-Type: application/json" \
137
+ -d '{"marketId":"559677","alertThreshold":0.75}' | jq
138
+
139
+ # Listar watchlist
140
+ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:7860/api/v1/watchlist | jq
141
+
142
+ # Eliminar de watchlist
143
+ curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
144
+ "http://localhost:7860/api/v1/watchlist/559677"
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Códigos de error
150
+
151
+ | HTTP | Código | Cuándo |
152
+ |---|---|---|
153
+ | `400` | `VALIDATION_ERROR` | Body inválido (marketId vacío, threshold fuera de 0–1) |
154
+ | `401` | `UNAUTHORIZED` | Sin token o token inválido |
155
+ | `404` | `NOT_FOUND` | Mercado no existe en DB o entrada no encontrada al borrar |
156
+ | `409` | `CONFLICT` | Mercado ya en watchlist |
157
+ | `500` | `INTERNAL` | Error inesperado |
backend/docs/insomnia-collection.json ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_type": "export",
3
+ "__export_format": 4,
4
+ "__export_date": "2026-05-16",
5
+ "__export_source": "polysignal-backend",
6
+ "resources": [
7
+ {
8
+ "_id": "env_polysignal",
9
+ "_type": "environment",
10
+ "name": "PolySignal Dev",
11
+ "data": {
12
+ "base_url": "http://localhost:7860",
13
+ "token": ""
14
+ }
15
+ },
16
+ {
17
+ "_id": "wrk_polysignal",
18
+ "_type": "workspace",
19
+ "name": "PolySignal Backend",
20
+ "description": "API REST PolySignal — mercados Polymarket + señales AI"
21
+ },
22
+ {
23
+ "_id": "grp_auth",
24
+ "_type": "request_group",
25
+ "name": "Auth",
26
+ "parentId": "wrk_polysignal"
27
+ },
28
+ {
29
+ "_id": "req_health",
30
+ "_type": "request",
31
+ "parentId": "grp_auth",
32
+ "name": "GET /health",
33
+ "method": "GET",
34
+ "url": "{{ _.base_url }}/api/v1/health",
35
+ "headers": [],
36
+ "body": {}
37
+ },
38
+ {
39
+ "_id": "req_login",
40
+ "_type": "request",
41
+ "parentId": "grp_auth",
42
+ "name": "POST /auth/login",
43
+ "method": "POST",
44
+ "url": "{{ _.base_url }}/api/v1/auth/login",
45
+ "headers": [{ "name": "Content-Type", "value": "application/json" }],
46
+ "body": {
47
+ "mimeType": "application/json",
48
+ "text": "{\n \"email\": \"admin@polysignal.test\",\n \"password\": \"Admin123!\"\n}"
49
+ }
50
+ },
51
+ {
52
+ "_id": "req_me",
53
+ "_type": "request",
54
+ "parentId": "grp_auth",
55
+ "name": "GET /auth/me",
56
+ "method": "GET",
57
+ "url": "{{ _.base_url }}/api/v1/auth/me",
58
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
59
+ "body": {}
60
+ },
61
+ {
62
+ "_id": "grp_markets",
63
+ "_type": "request_group",
64
+ "name": "Markets",
65
+ "parentId": "wrk_polysignal"
66
+ },
67
+ {
68
+ "_id": "req_markets_list",
69
+ "_type": "request",
70
+ "parentId": "grp_markets",
71
+ "name": "GET /markets",
72
+ "method": "GET",
73
+ "url": "{{ _.base_url }}/api/v1/markets",
74
+ "parameters": [
75
+ { "name": "limit", "value": "10" },
76
+ { "name": "offset", "value": "0" }
77
+ ],
78
+ "headers": [],
79
+ "body": {}
80
+ },
81
+ {
82
+ "_id": "req_markets_by_id",
83
+ "_type": "request",
84
+ "parentId": "grp_markets",
85
+ "name": "GET /markets/:id",
86
+ "method": "GET",
87
+ "url": "{{ _.base_url }}/api/v1/markets/559677",
88
+ "headers": [],
89
+ "body": {}
90
+ },
91
+ {
92
+ "_id": "req_market_signal",
93
+ "_type": "request",
94
+ "parentId": "grp_markets",
95
+ "name": "GET /markets/:id/signal",
96
+ "method": "GET",
97
+ "url": "{{ _.base_url }}/api/v1/markets/559677/signal",
98
+ "headers": [],
99
+ "body": {}
100
+ },
101
+ {
102
+ "_id": "grp_positions",
103
+ "_type": "request_group",
104
+ "name": "Positions",
105
+ "parentId": "wrk_polysignal"
106
+ },
107
+ {
108
+ "_id": "req_positions_open",
109
+ "_type": "request",
110
+ "parentId": "grp_positions",
111
+ "name": "POST /positions (open)",
112
+ "method": "POST",
113
+ "url": "{{ _.base_url }}/api/v1/positions",
114
+ "headers": [
115
+ { "name": "Authorization", "value": "Bearer {{ _.token }}" },
116
+ { "name": "Content-Type", "value": "application/json" }
117
+ ],
118
+ "body": {
119
+ "mimeType": "application/json",
120
+ "text": "{\n \"marketId\": \"559677\",\n \"outcome\": \"YES\",\n \"amountEur\": 100\n}"
121
+ }
122
+ },
123
+ {
124
+ "_id": "req_positions_list",
125
+ "_type": "request",
126
+ "parentId": "grp_positions",
127
+ "name": "GET /positions",
128
+ "method": "GET",
129
+ "url": "{{ _.base_url }}/api/v1/positions",
130
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
131
+ "body": {}
132
+ },
133
+ {
134
+ "_id": "req_positions_close",
135
+ "_type": "request",
136
+ "parentId": "grp_positions",
137
+ "name": "DELETE /positions/:id (close)",
138
+ "method": "DELETE",
139
+ "url": "{{ _.base_url }}/api/v1/positions/1",
140
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
141
+ "body": {}
142
+ },
143
+ {
144
+ "_id": "grp_watchlist",
145
+ "_type": "request_group",
146
+ "name": "Watchlist",
147
+ "parentId": "wrk_polysignal"
148
+ },
149
+ {
150
+ "_id": "req_watchlist_add",
151
+ "_type": "request",
152
+ "parentId": "grp_watchlist",
153
+ "name": "POST /watchlist",
154
+ "method": "POST",
155
+ "url": "{{ _.base_url }}/api/v1/watchlist",
156
+ "headers": [
157
+ { "name": "Authorization", "value": "Bearer {{ _.token }}" },
158
+ { "name": "Content-Type", "value": "application/json" }
159
+ ],
160
+ "body": {
161
+ "mimeType": "application/json",
162
+ "text": "{\n \"marketId\": \"559677\",\n \"alertThreshold\": 0.75\n}"
163
+ }
164
+ },
165
+ {
166
+ "_id": "req_watchlist_list",
167
+ "_type": "request",
168
+ "parentId": "grp_watchlist",
169
+ "name": "GET /watchlist",
170
+ "method": "GET",
171
+ "url": "{{ _.base_url }}/api/v1/watchlist",
172
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
173
+ "body": {}
174
+ },
175
+ {
176
+ "_id": "req_watchlist_delete",
177
+ "_type": "request",
178
+ "parentId": "grp_watchlist",
179
+ "name": "DELETE /watchlist/:marketId",
180
+ "method": "DELETE",
181
+ "url": "{{ _.base_url }}/api/v1/watchlist/559677",
182
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
183
+ "body": {}
184
+ },
185
+ {
186
+ "_id": "grp_alerts",
187
+ "_type": "request_group",
188
+ "name": "Alerts",
189
+ "parentId": "wrk_polysignal"
190
+ },
191
+ {
192
+ "_id": "req_alerts_list",
193
+ "_type": "request",
194
+ "parentId": "grp_alerts",
195
+ "name": "GET /alerts",
196
+ "method": "GET",
197
+ "url": "{{ _.base_url }}/api/v1/alerts",
198
+ "parameters": [
199
+ { "name": "limit", "value": "20" },
200
+ { "name": "offset", "value": "0" }
201
+ ],
202
+ "headers": [{ "name": "Authorization", "value": "Bearer {{ _.token }}" }],
203
+ "body": {}
204
+ }
205
+ ]
206
+ }
backend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "polysignal-backend",
3
+ "version": "1.0.0",
4
+ "description": "Backend API de PolySignal — Hackathon CIFO Barcelona La Violeta",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "prisma": {
8
+ "schema": "prisma/schema.prisma",
9
+ "seed": "node prisma/seed.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node src/index.js",
13
+ "dev": "node --watch src/index.js",
14
+ "db:migrate": "prisma migrate dev",
15
+ "db:generate": "prisma generate",
16
+ "db:seed": "node prisma/seed.js",
17
+ "db:studio": "prisma studio"
18
+ },
19
+ "dependencies": {
20
+ "@gradio/client": "^2.2.0",
21
+ "@prisma/client": "^6.19.2",
22
+ "bcryptjs": "^2.4.3",
23
+ "cors": "^2.8.5",
24
+ "dotenv": "^16.4.5",
25
+ "express": "^5.2.1",
26
+ "express-rate-limit": "^7.4.0",
27
+ "helmet": "^8.0.0",
28
+ "jsonwebtoken": "^9.0.2",
29
+ "node-cron": "^4.2.1",
30
+ "pino": "^9.5.0",
31
+ "prisma": "^6.19.2",
32
+ "socket.io": "^4.8.3",
33
+ "zod": "^3.23.8"
34
+ },
35
+ "engines": {
36
+ "node": ">=24.0.0"
37
+ }
38
+ }
backend/prisma/schema.prisma ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Schema de Prisma ORM para base de datos SQLite.
3
+ *
4
+ * Define 6 modelos: User, Market, AISignal, Position, Watchlist, Alert.
5
+ * Relaciones:
6
+ * - Market 1:N AISignal, Position, Watchlist, Alert
7
+ * - User 1:N Position, Watchlist, Alert
8
+ *
9
+ * No modificar sin consenso del equipo. Generar migraciones con:
10
+ * npx prisma migrate dev
11
+ * npx prisma generate
12
+ */
13
+
14
+ generator client {
15
+ provider = "prisma-client-js"
16
+ }
17
+
18
+ datasource db {
19
+ provider = "sqlite"
20
+ url = env("DATABASE_URL")
21
+ }
22
+
23
+ model User {
24
+ id Int @id @default(autoincrement())
25
+ email String @unique
26
+ passwordHash String
27
+ isActive Boolean @default(true)
28
+ telegramChatId String? // Configurado manualmente para demo
29
+ createdAt DateTime @default(now())
30
+ updatedAt DateTime @updatedAt
31
+
32
+ positions Position[]
33
+ watchlist Watchlist[]
34
+ alerts Alert[]
35
+ }
36
+
37
+ model Market {
38
+ id String @id // ID nativo de Polymarket
39
+ question String // Texto de la pregunta del mercado
40
+ category String? // politics | crypto | economics | sports
41
+ countryCode String? // ISO2 — usado por Leaflet para burbujas
42
+ yesPrice Float? // Precio YES: 0.0 a 1.0
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
54
+
55
+ signals AISignal[]
56
+ positions Position[]
57
+ watchlist Watchlist[]
58
+ alerts Alert[]
59
+ }
60
+
61
+ model AISignal {
62
+ id Int @id @default(autoincrement())
63
+ marketId String
64
+ market Market @relation(fields: [marketId], references: [id], onDelete: Cascade)
65
+ signal String // bullish | bearish | neutral
66
+ confidence Float // 0.0 a 1.0
67
+ summary String? // 2 frases generadas por Qwen3
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])
77
+ }
78
+
79
+ model Position {
80
+ id Int @id @default(autoincrement())
81
+ userId Int
82
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
83
+ marketId String
84
+ market Market @relation(fields: [marketId], references: [id], onDelete: Cascade)
85
+ outcome String // YES | NO
86
+ amountEur Float // Capital virtual apostado
87
+ entryPrice Float // Precio al abrir la posicion
88
+ currentPrice Float? // Precio actual (actualizado por scheduler)
89
+ pnl Float @default(0) // Profit and Loss calculado
90
+ kellyFraction Float? // Fraccion de Kelly al abrir
91
+ status String @default("open") // open | closed
92
+ openedAt DateTime @default(now())
93
+ closedAt DateTime?
94
+
95
+ @@index([userId, status])
96
+ @@index([marketId])
97
+ }
98
+
99
+ model Watchlist {
100
+ id Int @id @default(autoincrement())
101
+ userId Int
102
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
103
+ marketId String
104
+ market Market @relation(fields: [marketId], references: [id], onDelete: Cascade)
105
+ alertThreshold Float? // Umbral de precio para alerta Telegram
106
+ createdAt DateTime @default(now())
107
+
108
+ @@unique([userId, marketId])
109
+ @@index([userId])
110
+ }
111
+
112
+ model Alert {
113
+ id Int @id @default(autoincrement())
114
+ userId Int
115
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
116
+ marketId String
117
+ market Market @relation(fields: [marketId], references: [id], onDelete: Cascade)
118
+ type String // price_threshold | signal_change
119
+ message String // Texto enviado por Telegram
120
+ sentAt DateTime @default(now())
121
+
122
+ @@index([userId, sentAt])
123
+ @@index([marketId])
124
+ }
backend/prisma/seed.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import bcrypt from 'bcryptjs';
2
+ import { PrismaClient } from '@prisma/client';
3
+
4
+ const prisma = new PrismaClient();
5
+
6
+ const ROUNDS = Number(process.env.BCRYPT_ROUNDS ?? 10);
7
+
8
+ const users = [
9
+ { email: 'admin@polysignal.test', password: 'Admin123!' },
10
+ { email: 'user@polysignal.test', password: 'User123!' },
11
+ ];
12
+
13
+ const run = async () => {
14
+ for (const u of users) {
15
+ const passwordHash = await bcrypt.hash(u.password, ROUNDS);
16
+ const record = await prisma.user.upsert({
17
+ where: { email: u.email },
18
+ update: { passwordHash, isActive: true },
19
+ create: { email: u.email, passwordHash, isActive: true },
20
+ });
21
+ console.log(`seeded user ${record.email} (id=${record.id})`);
22
+ }
23
+ };
24
+
25
+ run()
26
+ .catch((err) => {
27
+ console.error(err);
28
+ process.exit(1);
29
+ })
30
+ .finally(async () => {
31
+ await prisma.$disconnect();
32
+ });
backend/src/config.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Configuracion centralizada de variables de entorno.
3
+ *
4
+ * Carga los valores de process.env mediante dotenv, los valida con Zod
5
+ * y expone constantes tipadas como: PORT, DATABASE_URL, JWT_SECRET,
6
+ * HF_TOKEN, HF_SPACE_MODERNFINBERT_URL, HF_SPACE_QWEN_URL, etc.
7
+ *
8
+ * Variables clave:
9
+ * - PORT: 7860 (requerido por HuggingFace Spaces).
10
+ * - DATABASE_URL: SQLite local para desarrollo, PostgreSQL para produccion.
11
+ * - JWT_SECRET: minimo 32 caracteres, usado para firmar tokens de autenticacion.
12
+ * - HF_TOKEN / HF_SPACE_*: credenciales para los Spaces de HuggingFace (IA).
13
+ * - OPENROUTER_API_KEY: fallback LLM si los Spaces estan saturados.
14
+ * - FINNHUB_API_KEY: noticias financieras para el pipeline de senales.
15
+ * - TELEGRAM_BOT_TOKEN: bot de alertas (@BotFather).
16
+ *
17
+ * Si falla la validacion, el proceso termina con error antes de levantar el servidor.
18
+ */
19
+
20
+ import 'dotenv/config';
21
+ import { z } from 'zod';
22
+
23
+ const schema = z.object({
24
+ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
25
+ PORT: z.coerce.number().int().positive().default(7860),
26
+ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
27
+ JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
28
+ JWT_EXPIRES_IN: z.string().default('1h'),
29
+ BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
30
+ CORS_ORIGIN: z.string().default('http://localhost:5173'),
31
+ LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
32
+ HF_TOKEN: z.string().optional(),
33
+ HF_SPACE_MODERNFINBERT_URL: z.string().optional(),
34
+ HF_SPACE_QWEN_URL: z.string().optional(),
35
+ OPENROUTER_API_KEY: z.string().optional(),
36
+ FINNHUB_API_KEY: z.string().optional(),
37
+ TELEGRAM_BOT_TOKEN: z.string().optional(),
38
+ });
39
+
40
+ const parsed = schema.safeParse(process.env);
41
+
42
+ if (!parsed.success) {
43
+ console.error('Invalid environment variables:');
44
+ console.error(parsed.error.flatten().fieldErrors);
45
+ process.exit(1);
46
+ }
47
+
48
+ export const config = Object.freeze(parsed.data);
frontend/index.html ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-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
+ </head>
9
+ <body>
10
+ <div id="app" class="layout">
11
+
12
+ <!-- Sidebar -->
13
+ <aside class="sidebar" id="sidebar">
14
+ <div class="sidebar-toggle" id="sidebar-toggle" title="Colapsar sidebar">◀</div>
15
+ <nav class="sidebar-nav">
16
+ <div class="nav-item active" data-view="dashboard">
17
+ <span class="nav-icon">◈</span>
18
+ <span class="nav-label">Panel</span>
19
+ </div>
20
+ <div class="nav-item" data-view="positions">
21
+ <span class="nav-icon">◫</span>
22
+ <span class="nav-label">Posiciones</span>
23
+ </div>
24
+ <div class="nav-item" data-view="watchlist">
25
+ <span class="nav-icon">☆</span>
26
+ <span class="nav-label">Seguimiento</span>
27
+ </div>
28
+ <div class="nav-item" data-view="alerts">
29
+ <span class="nav-icon">⚡</span>
30
+ <span class="nav-label">Alertas</span>
31
+ </div>
32
+ </nav>
33
+ <div class="sidebar-footer">
34
+ v0.1.0 · HF Spaces
35
+ </div>
36
+ </aside>
37
+
38
+ <!-- Topbar -->
39
+ <header class="topbar" id="topbar">
40
+ <div class="topbar-logo">
41
+ <div class="logo-dot"></div>
42
+ <span class="logo-text">PolySignal</span>
43
+ </div>
44
+ <div class="topbar-stats">
45
+ <div class="live-badge">
46
+ <div class="live-dot"></div>
47
+ EN VIVO
48
+ </div>
49
+ <div class="stats-track">
50
+ <div class="stat">
51
+ <span class="stat-label">Mercados</span>
52
+ <span class="stat-val" id="stat-markets">2.847</span>
53
+ </div>
54
+ <div class="stat">
55
+ <span class="stat-label">Volumen 24h</span>
56
+ <span class="stat-val" id="stat-volume">€4,2M</span>
57
+ <span class="stat-delta up" id="stat-volume-delta">+12,4%</span>
58
+ </div>
59
+ <div class="stat">
60
+ <span class="stat-label">Señales IA</span>
61
+ <span class="stat-val" id="stat-signals">183</span>
62
+ <span class="stat-delta up" id="stat-signals-delta">alcista</span>
63
+ </div>
64
+ <div class="stat">
65
+ <span class="stat-label">Alertas enviadas</span>
66
+ <span class="stat-val" id="stat-alerts">47</span>
67
+ <span class="stat-delta neutral">hoy</span>
68
+ </div>
69
+ <div class="legend end">
70
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
71
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
72
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
73
+ </div>
74
+ <!-- Duplicate for infinite scroll -->
75
+ <div class="stat">
76
+ <span class="stat-label">Mercados</span>
77
+ <span class="stat-val" id="stat-markets-dup">2.847</span>
78
+ </div>
79
+ <div class="stat">
80
+ <span class="stat-label">Volumen 24h</span>
81
+ <span class="stat-val" id="stat-volume-dup">€4,2M</span>
82
+ <span class="stat-delta up">+12,4%</span>
83
+ </div>
84
+ <div class="stat">
85
+ <span class="stat-label">Señales IA</span>
86
+ <span class="stat-val" id="stat-signals-dup">183</span>
87
+ <span class="stat-delta up">alcista</span>
88
+ </div>
89
+ <div class="stat">
90
+ <span class="stat-label">Alertas enviadas</span>
91
+ <span class="stat-val" id="stat-alerts-dup">47</span>
92
+ <span class="stat-delta neutral">hoy</span>
93
+ </div>
94
+ <div class="legend">
95
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
96
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
97
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
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>
118
+ <button class="icon-btn" id="btn-notif" title="Notificaciones">◉</button>
119
+ <button class="btn-ghost desktop-only" id="btn-auth">Entrar</button>
120
+ <button class="icon-btn auth-indicator mobile-only" id="btn-auth-mobile" title="Entrar"></button>
121
+ </div>
122
+ </header>
123
+
124
+ <!-- Main content area -->
125
+ <main class="main" id="main">
126
+
127
+ <!-- DASHBOARD VIEW -->
128
+ <section class="view active" id="view-dashboard">
129
+ <div class="dashboard-grid">
130
+
131
+ <!-- Map Panel -->
132
+ <div class="panel map-panel" id="panel-map">
133
+ <div class="panel-header" data-panel="map">
134
+ <div class="panel-title">
135
+ <span>◈</span>
136
+ Mapa global
137
+ </div>
138
+ <div class="map-legend">
139
+ <div class="legend-item"><div class="legend-dot green"></div>alcista</div>
140
+ <div class="legend-item"><div class="legend-dot red"></div>bajista</div>
141
+ <div class="legend-item"><div class="legend-dot gray"></div>neutral</div>
142
+ </div>
143
+ <span class="panel-toggle">▼</span>
144
+ </div>
145
+ <div class="panel-body">
146
+ <div id="map-container"></div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Signals Panel -->
151
+ <div class="panel signals-panel" id="panel-signals">
152
+ <div class="panel-header" data-panel="signals">
153
+ <div class="panel-title">
154
+ <span>◈</span>
155
+ Señales IA — mercados top
156
+ </div>
157
+ <span class="panel-toggle">▼</span>
158
+ </div>
159
+ <div class="panel-body">
160
+ <div class="signals-list" id="signals-list"></div>
161
+ <div class="positions-separator">
162
+ <div class="panel-title mb-sm">Mis posiciones</div>
163
+ <div id="mini-positions"></div>
164
+ </div>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Detail Panel -->
169
+ <div class="panel detail-panel" id="panel-detail">
170
+ <div class="panel-header" data-panel="detail">
171
+ <div class="panel-title">
172
+ <span>◈</span>
173
+ Detalle del mercado
174
+ </div>
175
+ <span class="panel-toggle">▼</span>
176
+ </div>
177
+ <div class="panel-body" id="detail-body">
178
+ <!-- Dynamic content -->
179
+ </div>
180
+ </div>
181
+
182
+ </div>
183
+ </section>
184
+
185
+ <!-- POSITIONS VIEW -->
186
+ <section class="view" id="view-positions">
187
+ <div class="panel full-height">
188
+ <div class="panel-header">
189
+ <div class="panel-title"><span>◫</span> Simulador — Posiciones abiertas</div>
190
+ </div>
191
+ <div class="panel-body">
192
+ <div class="table-wrap">
193
+ <table id="positions-table">
194
+ <thead>
195
+ <tr>
196
+ <th>Mercado</th>
197
+ <th>Resultado</th>
198
+ <th>Cantidad</th>
199
+ <th>Entrada</th>
200
+ <th>Actual</th>
201
+ <th>G&amp;P</th>
202
+ <th>Kelly</th>
203
+ <th>Abierta</th>
204
+ <th></th>
205
+ </tr>
206
+ </thead>
207
+ <tbody></tbody>
208
+ </table>
209
+ </div>
210
+ <div class="empty-state hidden" id="positions-empty">No hay posiciones abiertas. Ve al Panel para simular una operación.</div>
211
+ </div>
212
+ </div>
213
+ </section>
214
+
215
+ <!-- WATCHLIST VIEW -->
216
+ <section class="view" id="view-watchlist">
217
+ <div class="panel full-height">
218
+ <div class="panel-header">
219
+ <div class="panel-title"><span>☆</span> Lista de seguimiento</div>
220
+ </div>
221
+ <div class="panel-body">
222
+ <div class="table-wrap">
223
+ <table id="watchlist-table">
224
+ <thead>
225
+ <tr>
226
+ <th>Mercado</th>
227
+ <th>Categoría</th>
228
+ <th>Sí</th>
229
+ <th>No</th>
230
+ <th>Señal</th>
231
+ <th>Volumen</th>
232
+ <th>Umbral de alerta</th>
233
+ <th></th>
234
+ </tr>
235
+ </thead>
236
+ <tbody></tbody>
237
+ </table>
238
+ </div>
239
+ <div class="empty-state hidden" id="watchlist-empty">Tu lista de seguimiento está vacía. Añade mercados desde el Panel.</div>
240
+ </div>
241
+ </div>
242
+ </section>
243
+
244
+ <!-- ALERTS VIEW -->
245
+ <section class="view" id="view-alerts">
246
+ <div class="panel full-height">
247
+ <div class="panel-header">
248
+ <div class="panel-title"><span>⚡</span> Historial de alertas</div>
249
+ </div>
250
+ <div class="panel-body">
251
+ <div class="table-wrap">
252
+ <table id="alerts-table">
253
+ <thead>
254
+ <tr>
255
+ <th>Hora</th>
256
+ <th>Mercado</th>
257
+ <th>Tipo</th>
258
+ <th>Mensaje</th>
259
+ </tr>
260
+ </thead>
261
+ <tbody></tbody>
262
+ </table>
263
+ </div>
264
+ <div class="empty-state hidden" id="alerts-empty">Aún no se han enviado alertas.</div>
265
+ </div>
266
+ </div>
267
+ </section>
268
+
269
+ </main>
270
+ </div>
271
+
272
+ <!-- Telegram Alerts Modal -->
273
+ <div class="modal-overlay hidden" id="telegram-modal">
274
+ <div class="modal">
275
+ <div class="modal-header">
276
+ <div class="modal-title">
277
+ <span class="modal-title-icon">✈</span>
278
+ <span>Alertas Telegram</span>
279
+ </div>
280
+ <button class="modal-close" id="telegram-modal-close" title="Cerrar">✕</button>
281
+ </div>
282
+ <div class="modal-body">
283
+ <!-- Instructions -->
284
+ <div class="telegram-instructions">
285
+ <div class="instruction-step">
286
+ <span class="step-num">1</span>
287
+ <div class="step-body">
288
+ <strong>Crea tu bot</strong>
289
+ <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>
290
+ </div>
291
+ </div>
292
+ <div class="instruction-step">
293
+ <span class="step-num">2</span>
294
+ <div class="step-body">
295
+ <strong>Obtén tu Chat ID</strong>
296
+ <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>
297
+ </div>
298
+ </div>
299
+ <div class="instruction-step">
300
+ <span class="step-num">3</span>
301
+ <div class="step-body">
302
+ <strong>Inicia el bot</strong>
303
+ <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>
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <form class="modal-form active" id="form-telegram">
309
+ <div class="form-group">
310
+ <label for="telegram-bot-token">Token del bot</label>
311
+ <input type="password" id="telegram-bot-token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" />
312
+ </div>
313
+ <div class="form-group">
314
+ <label for="telegram-chat-id">Chat ID</label>
315
+ <input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
316
+ </div>
317
+ <div class="form-group">
318
+ <div class="toggle-row">
319
+ <label for="telegram-enabled">Activar alertas</label>
320
+ <label class="toggle-switch">
321
+ <input type="checkbox" id="telegram-enabled" />
322
+ <span class="toggle-slider"></span>
323
+ </label>
324
+ </div>
325
+ </div>
326
+ <div class="form-status" id="telegram-status"></div>
327
+ <div class="form-actions">
328
+ <button type="button" class="modal-submit modal-submit--secondary" id="btn-test-telegram">Probar conexión</button>
329
+ <button type="submit" class="modal-submit">Guardar configuración</button>
330
+ </div>
331
+ </form>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- Auth Modal -->
337
+ <div class="modal-overlay hidden" id="auth-modal">
338
+ <div class="modal">
339
+ <div class="modal-header">
340
+ <div class="modal-tabs">
341
+ <button class="modal-tab active" data-tab="login">Iniciar sesión</button>
342
+ <button class="modal-tab" data-tab="register">Registrarse</button>
343
+ </div>
344
+ <button class="modal-close" id="modal-close" title="Cerrar">✕</button>
345
+ </div>
346
+ <div class="modal-body">
347
+ <!-- Login Form -->
348
+ <form class="modal-form active" id="form-login">
349
+ <div class="form-group">
350
+ <label for="login-email">Correo electrónico</label>
351
+ <input type="email" id="login-email" placeholder="usuario@ejemplo.com" required />
352
+ </div>
353
+ <div class="form-group">
354
+ <label for="login-password">Contraseña</label>
355
+ <input type="password" id="login-password" placeholder="••••••••" required />
356
+ </div>
357
+ <div class="form-error" id="login-error"></div>
358
+ <button type="submit" class="modal-submit">Entrar</button>
359
+ </form>
360
+
361
+ <!-- Register Form -->
362
+ <form class="modal-form" id="form-register">
363
+ <div class="form-group">
364
+ <label for="register-email">Correo electrónico</label>
365
+ <input type="email" id="register-email" placeholder="usuario@ejemplo.com" required />
366
+ </div>
367
+ <div class="form-group">
368
+ <label for="register-password">Contraseña</label>
369
+ <input type="password" id="register-password" placeholder="Mínimo 8 caracteres" required minlength="8" />
370
+ </div>
371
+ <div class="form-group">
372
+ <label for="register-password-confirm">Confirmar contraseña</label>
373
+ <input type="password" id="register-password-confirm" placeholder="Repite la contraseña" required />
374
+ </div>
375
+ <div class="form-error" id="register-error"></div>
376
+ <button type="submit" class="modal-submit">Crear cuenta</button>
377
+ </form>
378
+ </div>
379
+ </div>
380
+ </div>
381
+
382
+ <script type="module" src="/src/main.js"></script>
383
+ </body>
384
+ </html>
frontend/package.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "devDependencies": {
12
+ "vite": "^7.3.3"
13
+ },
14
+ "engines": {
15
+ "node": ">=24.0.0"
16
+ },
17
+ "dependencies": {
18
+ "chart.js": "^4.5.1",
19
+ "leaflet": "^1.9.4",
20
+ "socket.io-client": "^4.8.3"
21
+ }
22
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+
3
+ export default defineConfig({
4
+ server: {
5
+ port: 5173,
6
+ proxy: {
7
+ '/api': {
8
+ target: 'http://localhost:7860',
9
+ changeOrigin: true,
10
+ },
11
+ '/socket.io': {
12
+ target: 'http://localhost:7860',
13
+ changeOrigin: true,
14
+ ws: true,
15
+ },
16
+ },
17
+ },
18
+ build: {
19
+ outDir: 'dist',
20
+ emptyOutDir: true,
21
+ },
22
+ })