germansango01 commited on
Commit
bce154c
·
1 Parent(s): b6ee67e

flow completo backend polymarket y socket.io implementado

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. backend/docs/ALERTS.md +129 -0
  2. backend/docs/API.md +137 -0
  3. backend/docs/MARKETS.md +193 -0
  4. backend/docs/POSITIONS.md +196 -0
  5. backend/docs/SIGNALS.md +128 -0
  6. backend/docs/WATCHLIST.md +157 -0
  7. backend/docs/insomnia-collection.json +206 -0
  8. backend/src/alerts/alerts.controller.js +9 -0
  9. backend/src/alerts/alerts.repository.js +23 -0
  10. backend/src/alerts/alerts.routes.js +11 -0
  11. backend/src/alerts/alerts.service.js +42 -0
  12. backend/src/alerts/telegram.client.js +16 -0
  13. backend/src/app.js +20 -0
  14. backend/src/config.js +4 -0
  15. backend/src/index.js +9 -0
  16. backend/src/markets/markets.controller.js +15 -0
  17. backend/src/markets/markets.repository.js +32 -0
  18. backend/src/markets/markets.routes.js +11 -0
  19. backend/src/markets/markets.service.js +18 -0
  20. backend/src/markets/markets.validators.js +12 -0
  21. backend/src/markets/polymarket.client.js +45 -0
  22. backend/src/middlewares/validate.js +6 -1
  23. backend/src/positions/kelly.js +12 -0
  24. backend/src/positions/positions.controller.js +19 -0
  25. backend/src/positions/positions.repository.js +30 -0
  26. backend/src/positions/positions.routes.js +15 -0
  27. backend/src/positions/positions.service.js +76 -0
  28. backend/src/positions/positions.validators.js +15 -0
  29. backend/src/routes/alerts.js +0 -9
  30. backend/src/routes/markets.js +0 -9
  31. backend/src/routes/positions.js +0 -10
  32. backend/src/routes/signals.js +0 -9
  33. backend/src/routes/watchlist.js +0 -10
  34. backend/src/scheduler.js +61 -9
  35. backend/src/services/aiPipeline.js +0 -12
  36. backend/src/services/finnhub.js +0 -11
  37. backend/src/services/kelly.js +0 -15
  38. backend/src/services/polymarket.js +0 -11
  39. backend/src/services/telegramBot.js +0 -12
  40. backend/src/signals/aiPipeline.js +176 -0
  41. backend/src/signals/finnhub.client.js +24 -0
  42. backend/src/signals/signals.controller.js +9 -0
  43. backend/src/signals/signals.repository.js +16 -0
  44. backend/src/signals/signals.routes.js +9 -0
  45. backend/src/signals/signals.service.js +25 -0
  46. backend/src/socket/broadcaster.js +17 -10
  47. backend/src/utils/httpClient.js +73 -0
  48. backend/src/watchlist/watchlist.controller.js +19 -0
  49. backend/src/watchlist/watchlist.repository.js +31 -0
  50. backend/src/watchlist/watchlist.routes.js +15 -0
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/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/src/alerts/alerts.controller.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { ok } from '../utils/apiResponse.js';
2
+ import { alertsService } from './alerts.service.js';
3
+
4
+ export const alertsController = {
5
+ async list(req, res) {
6
+ const alerts = await alertsService.list(req.user.id, req.query);
7
+ ok(res, alerts);
8
+ },
9
+ };
backend/src/alerts/alerts.repository.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from '../utils/prisma.js';
2
+
3
+ export const alertsRepository = {
4
+ create({ userId, marketId, type, message }) {
5
+ return prisma.alert.create({ data: { userId, marketId, type, message } });
6
+ },
7
+
8
+ findByUser(userId, { limit = 50, offset = 0 } = {}) {
9
+ return prisma.alert.findMany({
10
+ where: { userId },
11
+ orderBy: { sentAt: 'desc' },
12
+ take: limit,
13
+ skip: offset,
14
+ include: { market: { select: { id: true, question: true } } },
15
+ });
16
+ },
17
+
18
+ findRecent(userId, marketId, type, windowMs) {
19
+ return prisma.alert.findFirst({
20
+ where: { userId, marketId, type, sentAt: { gte: new Date(Date.now() - windowMs) } },
21
+ });
22
+ },
23
+ };
backend/src/alerts/alerts.routes.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { requireAuth } from '../middlewares/requireAuth.js';
3
+ import { alertsController } from './alerts.controller.js';
4
+
5
+ const router = Router();
6
+
7
+ router.use(requireAuth);
8
+
9
+ router.get('/', alertsController.list);
10
+
11
+ export default router;
backend/src/alerts/alerts.service.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+ import { alertsRepository } from './alerts.repository.js';
3
+ import { watchlistRepository } from '../watchlist/watchlist.repository.js';
4
+ import { sendMessage } from './telegram.client.js';
5
+ import { emitPriceAlert } from '../socket/broadcaster.js';
6
+ import { logger } from '../utils/logger.js';
7
+
8
+ const listQuery = z.object({
9
+ limit: z.coerce.number().int().min(1).max(200).default(50),
10
+ offset: z.coerce.number().int().min(0).default(0),
11
+ });
12
+
13
+ const DEDUP_WINDOW_MS = 5 * 60 * 1000; // 5 min
14
+
15
+ export const alertsService = {
16
+ list(userId, query) {
17
+ const { limit, offset } = listQuery.parse(query);
18
+ return alertsRepository.findByUser(userId, { limit, offset });
19
+ },
20
+
21
+ async processAll() {
22
+ const entries = await watchlistRepository.findAllWithThreshold();
23
+
24
+ for (const entry of entries) {
25
+ const { alertThreshold, user, market } = entry;
26
+ if (!market.yesPrice || market.yesPrice < alertThreshold) continue;
27
+
28
+ const recent = await alertsRepository.findRecent(user.id, market.id, 'price_threshold', DEDUP_WINDOW_MS);
29
+ if (recent) continue;
30
+
31
+ const pct = (market.yesPrice * 100).toFixed(1);
32
+ const threshold = (alertThreshold * 100).toFixed(1);
33
+ const message =
34
+ `<b>Price Alert</b>\n${market.question}\nYES: ${pct}% ≥ threshold ${threshold}%`;
35
+
36
+ await alertsRepository.create({ userId: user.id, marketId: market.id, type: 'price_threshold', message });
37
+ await sendMessage(user.telegramChatId, message);
38
+ emitPriceAlert({ marketId: market.id, type: 'price_threshold', message });
39
+ logger.info({ marketId: market.id, userId: user.id }, 'price alert sent');
40
+ }
41
+ },
42
+ };
backend/src/alerts/telegram.client.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpPost } from '../utils/httpClient.js';
2
+ import { config } from '../config.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export async function sendMessage(chatId, text) {
6
+ if (!config.TELEGRAM_BOT_TOKEN || !chatId) return;
7
+ try {
8
+ await httpPost(
9
+ `https://api.telegram.org/bot${config.TELEGRAM_BOT_TOKEN}/sendMessage`,
10
+ { chat_id: chatId, text, parse_mode: 'HTML' },
11
+ { retries: 1, timeout: 8_000 },
12
+ );
13
+ } catch (err) {
14
+ logger.warn({ err: err.message, chatId }, 'telegram send failed');
15
+ }
16
+ }
backend/src/app.js CHANGED
@@ -1,9 +1,15 @@
1
  import express from 'express';
2
  import cors from 'cors';
3
  import helmet from 'helmet';
 
4
  import { config } from './config.js';
5
  import { ok } from './utils/apiResponse.js';
6
  import authRoutes from './auth/auth.routes.js';
 
 
 
 
 
7
  import { notFound } from './middlewares/notFound.js';
8
  import { errorHandler } from './middlewares/errorHandler.js';
9
 
@@ -11,10 +17,24 @@ const app = express();
11
 
12
  app.use(helmet());
13
  app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
 
 
 
 
 
 
 
 
 
14
  app.use(express.json({ limit: '1mb' }));
15
 
16
  app.get('/api/v1/health', (_req, res) => ok(res, { status: 'up' }));
17
  app.use('/api/v1/auth', authRoutes);
 
 
 
 
 
18
 
19
  app.use(notFound);
20
  app.use(errorHandler);
 
1
  import express from 'express';
2
  import cors from 'cors';
3
  import helmet from 'helmet';
4
+ import rateLimit from 'express-rate-limit';
5
  import { config } from './config.js';
6
  import { ok } from './utils/apiResponse.js';
7
  import authRoutes from './auth/auth.routes.js';
8
+ import marketsRoutes from './markets/markets.routes.js';
9
+ import signalsRoutes from './signals/signals.routes.js';
10
+ import positionsRoutes from './positions/positions.routes.js';
11
+ import watchlistRoutes from './watchlist/watchlist.routes.js';
12
+ import alertsRoutes from './alerts/alerts.routes.js';
13
  import { notFound } from './middlewares/notFound.js';
14
  import { errorHandler } from './middlewares/errorHandler.js';
15
 
 
17
 
18
  app.use(helmet());
19
  app.use(cors({ origin: config.CORS_ORIGIN, credentials: true }));
20
+ app.use(
21
+ rateLimit({
22
+ windowMs: 15 * 60 * 1000,
23
+ max: 200,
24
+ standardHeaders: true,
25
+ legacyHeaders: false,
26
+ message: { ok: false, error: { code: 'TOO_MANY_REQUESTS', message: 'Rate limit exceeded' } },
27
+ }),
28
+ );
29
  app.use(express.json({ limit: '1mb' }));
30
 
31
  app.get('/api/v1/health', (_req, res) => ok(res, { status: 'up' }));
32
  app.use('/api/v1/auth', authRoutes);
33
+ app.use('/api/v1/markets', marketsRoutes);
34
+ app.use('/api/v1/markets', signalsRoutes);
35
+ app.use('/api/v1/positions', positionsRoutes);
36
+ app.use('/api/v1/watchlist', watchlistRoutes);
37
+ app.use('/api/v1/alerts', alertsRoutes);
38
 
39
  app.use(notFound);
40
  app.use(errorHandler);
backend/src/config.js CHANGED
@@ -10,6 +10,10 @@ const schema = z.object({
10
  BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
11
  CORS_ORIGIN: z.string().default('http://localhost:5173'),
12
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
 
 
 
 
13
  });
14
 
15
  const parsed = schema.safeParse(process.env);
 
10
  BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
11
  CORS_ORIGIN: z.string().default('http://localhost:5173'),
12
  LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
13
+ HF_TOKEN: z.string().optional(),
14
+ OPENROUTER_API_KEY: z.string().optional(),
15
+ FINNHUB_API_KEY: z.string().optional(),
16
+ TELEGRAM_BOT_TOKEN: z.string().optional(),
17
  });
18
 
19
  const parsed = schema.safeParse(process.env);
backend/src/index.js CHANGED
@@ -1,15 +1,24 @@
1
  import http from 'node:http';
 
2
  import app from './app.js';
3
  import { config } from './config.js';
4
  import { logger } from './utils/logger.js';
5
  import { prisma } from './utils/prisma.js';
 
 
6
 
7
  const httpServer = http.createServer(app);
 
 
8
 
9
  httpServer.listen(config.PORT, () => {
10
  logger.info({ port: config.PORT, env: config.NODE_ENV }, 'PolySignal backend up');
11
  });
12
 
 
 
 
 
13
  for (const sig of ['SIGTERM', 'SIGINT']) {
14
  process.on(sig, async () => {
15
  logger.info({ sig }, 'shutting down');
 
1
  import http from 'node:http';
2
+ import { Server as IOServer } from 'socket.io';
3
  import app from './app.js';
4
  import { config } from './config.js';
5
  import { logger } from './utils/logger.js';
6
  import { prisma } from './utils/prisma.js';
7
+ import { attachBroadcaster } from './socket/broadcaster.js';
8
+ import { startScheduler } from './scheduler.js';
9
 
10
  const httpServer = http.createServer(app);
11
+ const io = new IOServer(httpServer, { cors: { origin: config.CORS_ORIGIN } });
12
+ attachBroadcaster(io);
13
 
14
  httpServer.listen(config.PORT, () => {
15
  logger.info({ port: config.PORT, env: config.NODE_ENV }, 'PolySignal backend up');
16
  });
17
 
18
+ if (config.NODE_ENV !== 'test') {
19
+ startScheduler();
20
+ }
21
+
22
  for (const sig of ['SIGTERM', 'SIGINT']) {
23
  process.on(sig, async () => {
24
  logger.info({ sig }, 'shutting down');
backend/src/markets/markets.controller.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ok } from '../utils/apiResponse.js';
2
+ import { marketsService } from './markets.service.js';
3
+
4
+ export const marketsController = {
5
+ async list(req, res) {
6
+ const { limit, offset, category, status } = req.query;
7
+ const { data, total } = await marketsService.list({ limit, offset, category, status });
8
+ ok(res, data, { total, limit, offset });
9
+ },
10
+
11
+ async getById(req, res) {
12
+ const market = await marketsService.getById(req.params.id);
13
+ ok(res, market);
14
+ },
15
+ };
backend/src/markets/markets.repository.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from '../utils/prisma.js';
2
+
3
+ export const marketsRepository = {
4
+ findMany({ limit, offset, category, status }) {
5
+ const where = { status };
6
+ if (category) where.category = category;
7
+ return prisma.market.findMany({
8
+ where,
9
+ orderBy: { volumeEur: 'desc' },
10
+ take: limit,
11
+ skip: offset,
12
+ });
13
+ },
14
+
15
+ count({ category, status }) {
16
+ const where = { status };
17
+ if (category) where.category = category;
18
+ return prisma.market.count({ where });
19
+ },
20
+
21
+ findById(id) {
22
+ return prisma.market.findUnique({ where: { id } });
23
+ },
24
+
25
+ upsert(market) {
26
+ return prisma.market.upsert({
27
+ where: { id: market.id },
28
+ update: market,
29
+ create: market,
30
+ });
31
+ },
32
+ };
backend/src/markets/markets.routes.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { validate } from '../middlewares/validate.js';
3
+ import { marketsController } from './markets.controller.js';
4
+ import { listQuery, idParam } from './markets.validators.js';
5
+
6
+ const router = Router();
7
+
8
+ router.get('/', validate(listQuery, 'query'), marketsController.list);
9
+ router.get('/:id', validate(idParam, 'params'), marketsController.getById);
10
+
11
+ export default router;
backend/src/markets/markets.service.js ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from '../utils/apiResponse.js';
2
+ import { marketsRepository } from './markets.repository.js';
3
+
4
+ export const marketsService = {
5
+ async list(query) {
6
+ const [data, total] = await Promise.all([
7
+ marketsRepository.findMany(query),
8
+ marketsRepository.count(query),
9
+ ]);
10
+ return { data, total };
11
+ },
12
+
13
+ async getById(id) {
14
+ const market = await marketsRepository.findById(id);
15
+ if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
16
+ return market;
17
+ },
18
+ };
backend/src/markets/markets.validators.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ export const listQuery = z.object({
4
+ limit: z.coerce.number().int().min(1).max(100).default(20),
5
+ offset: z.coerce.number().int().min(0).default(0),
6
+ category: z.enum(['politics', 'crypto', 'economics', 'sports']).optional(),
7
+ status: z.enum(['active', 'closed', 'resolved']).default('active'),
8
+ });
9
+
10
+ export const idParam = z.object({
11
+ id: z.string().min(1),
12
+ });
backend/src/markets/polymarket.client.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpGet } from '../utils/httpClient.js';
2
+
3
+ const GAMMA_URL = 'https://gamma-api.polymarket.com/markets';
4
+ const USD_TO_EUR = 0.93;
5
+
6
+ function mapStatus({ active, closed, archived }) {
7
+ if (archived) return 'resolved';
8
+ if (closed) return 'closed';
9
+ return 'active';
10
+ }
11
+
12
+ function parsePrices(outcomePrices) {
13
+ try {
14
+ const arr = JSON.parse(outcomePrices);
15
+ return {
16
+ yesPrice: arr[0] != null ? parseFloat(arr[0]) : null,
17
+ noPrice: arr[1] != null ? parseFloat(arr[1]) : null,
18
+ };
19
+ } catch {
20
+ return { yesPrice: null, noPrice: null };
21
+ }
22
+ }
23
+
24
+ export function mapMarket(raw) {
25
+ const { yesPrice, noPrice } = parsePrices(raw.outcomePrices);
26
+ return {
27
+ id: String(raw.id),
28
+ question: raw.question,
29
+ category: null,
30
+ countryCode: null,
31
+ yesPrice,
32
+ noPrice,
33
+ volumeEur: raw.volume != null ? parseFloat(raw.volume) * USD_TO_EUR : null,
34
+ liquidityEur: raw.liquidity != null ? parseFloat(raw.liquidity) * USD_TO_EUR : null,
35
+ status: mapStatus(raw),
36
+ closesAt: raw.endDate ? new Date(raw.endDate) : null,
37
+ lastSynced: new Date(),
38
+ };
39
+ }
40
+
41
+ export async function fetchActiveMarkets(limit = 100) {
42
+ const url = `${GAMMA_URL}?active=true&closed=false&limit=${limit}`;
43
+ const raw = await httpGet(url);
44
+ return raw.map(mapMarket);
45
+ }
backend/src/middlewares/validate.js CHANGED
@@ -7,6 +7,11 @@ export const validate = (schema, source = 'body') => (req, _res, next) => {
7
  new HttpError(400, 'VALIDATION_ERROR', 'Invalid request', result.error.flatten().fieldErrors),
8
  );
9
  }
10
- req[source] = result.data;
 
 
 
 
 
11
  next();
12
  };
 
7
  new HttpError(400, 'VALIDATION_ERROR', 'Invalid request', result.error.flatten().fieldErrors),
8
  );
9
  }
10
+ // req.query is a getter in Express 5 — override it with a data property
11
+ if (source === 'query') {
12
+ Object.defineProperty(req, 'query', { value: result.data, writable: true, configurable: true });
13
+ } else {
14
+ req[source] = result.data;
15
+ }
16
  next();
17
  };
backend/src/positions/kelly.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Kelly criterion capped at 25%.
3
+ * k = (odds * pWin - pLose) / odds
4
+ * odds = (1 / price) - 1
5
+ */
6
+ export function kellyFraction(price, pWin) {
7
+ if (!price || price <= 0 || price >= 1 || !pWin) return 0;
8
+ const pLose = 1 - pWin;
9
+ const odds = 1 / price - 1;
10
+ const k = (odds * pWin - pLose) / odds;
11
+ return Math.min(Math.max(k, 0), 0.25);
12
+ }
backend/src/positions/positions.controller.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ok, created, noContent } from '../utils/apiResponse.js';
2
+ import { positionsService } from './positions.service.js';
3
+
4
+ export const positionsController = {
5
+ async open(req, res) {
6
+ const position = await positionsService.open(req.user.id, req.body);
7
+ created(res, position);
8
+ },
9
+
10
+ async list(req, res) {
11
+ const positions = await positionsService.list(req.user.id, req.query.status);
12
+ ok(res, positions);
13
+ },
14
+
15
+ async close(req, res) {
16
+ const position = await positionsService.close(req.params.id, req.user.id);
17
+ ok(res, position);
18
+ },
19
+ };
backend/src/positions/positions.repository.js ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from '../utils/prisma.js';
2
+
3
+ export const positionsRepository = {
4
+ create(data) {
5
+ return prisma.position.create({ data });
6
+ },
7
+
8
+ findByUser(userId, status) {
9
+ return prisma.position.findMany({
10
+ where: { userId, ...(status ? { status } : {}) },
11
+ include: { market: { select: { id: true, question: true, yesPrice: true, noPrice: true, status: true } } },
12
+ orderBy: { openedAt: 'desc' },
13
+ });
14
+ },
15
+
16
+ findByIdAndUser(id, userId) {
17
+ return prisma.position.findFirst({ where: { id, userId } });
18
+ },
19
+
20
+ findAllOpen() {
21
+ return prisma.position.findMany({
22
+ where: { status: 'open' },
23
+ include: { market: { select: { yesPrice: true, noPrice: true } } },
24
+ });
25
+ },
26
+
27
+ update(id, data) {
28
+ return prisma.position.update({ where: { id }, data });
29
+ },
30
+ };
backend/src/positions/positions.routes.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { validate } from '../middlewares/validate.js';
3
+ import { requireAuth } from '../middlewares/requireAuth.js';
4
+ import { positionsController } from './positions.controller.js';
5
+ import { openBody, idParam, listQuery } from './positions.validators.js';
6
+
7
+ const router = Router();
8
+
9
+ router.use(requireAuth);
10
+
11
+ router.post('/', validate(openBody), positionsController.open);
12
+ router.get('/', validate(listQuery, 'query'), positionsController.list);
13
+ router.delete('/:id', validate(idParam, 'params'), positionsController.close);
14
+
15
+ export default router;
backend/src/positions/positions.service.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from '../utils/apiResponse.js';
2
+ import { positionsRepository } from './positions.repository.js';
3
+ import { marketsRepository } from '../markets/markets.repository.js';
4
+ import { signalsRepository } from '../signals/signals.repository.js';
5
+ import { kellyFraction } from './kelly.js';
6
+
7
+ function currentPriceForOutcome(market, outcome) {
8
+ return outcome === 'YES' ? market.yesPrice : market.noPrice;
9
+ }
10
+
11
+ function calcPnl(amountEur, entryPrice, currentPrice) {
12
+ if (!currentPrice || !entryPrice) return 0;
13
+ return amountEur * (currentPrice / entryPrice - 1);
14
+ }
15
+
16
+ export const positionsService = {
17
+ async open(userId, { marketId, outcome, amountEur }) {
18
+ const market = await marketsRepository.findById(marketId);
19
+ if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
20
+ if (market.status !== 'active') throw new HttpError(409, 'MARKET_CLOSED', 'Market is not active');
21
+
22
+ const entryPrice = currentPriceForOutcome(market, outcome);
23
+ if (!entryPrice) throw new HttpError(409, 'NO_PRICE', 'Market price unavailable');
24
+
25
+ const latestSignal = await signalsRepository.findLatestByMarket(marketId);
26
+ const confidence = latestSignal?.confidence ?? 0.5;
27
+ const fraction = kellyFraction(entryPrice, confidence);
28
+
29
+ return positionsRepository.create({
30
+ userId,
31
+ marketId,
32
+ outcome,
33
+ amountEur,
34
+ entryPrice,
35
+ currentPrice: entryPrice,
36
+ pnl: 0,
37
+ kellyFraction: fraction,
38
+ status: 'open',
39
+ });
40
+ },
41
+
42
+ list(userId, status) {
43
+ return positionsRepository.findByUser(userId, status);
44
+ },
45
+
46
+ async close(id, userId) {
47
+ const position = await positionsRepository.findByIdAndUser(id, userId);
48
+ if (!position) throw new HttpError(404, 'NOT_FOUND', 'Position not found');
49
+ if (position.status === 'closed') throw new HttpError(409, 'ALREADY_CLOSED', 'Position already closed');
50
+
51
+ const market = await marketsRepository.findById(position.marketId);
52
+ const currentPrice = market ? currentPriceForOutcome(market, position.outcome) : position.entryPrice;
53
+ const finalPnl = calcPnl(position.amountEur, position.entryPrice, currentPrice);
54
+
55
+ return positionsRepository.update(id, {
56
+ status: 'closed',
57
+ currentPrice,
58
+ pnl: finalPnl,
59
+ closedAt: new Date(),
60
+ });
61
+ },
62
+
63
+ async updateAllPnL() {
64
+ const open = await positionsRepository.findAllOpen();
65
+ await Promise.all(
66
+ open.map((pos) => {
67
+ const currentPrice = currentPriceForOutcome(pos.market, pos.outcome);
68
+ if (!currentPrice) return Promise.resolve();
69
+ return positionsRepository.update(pos.id, {
70
+ currentPrice,
71
+ pnl: calcPnl(pos.amountEur, pos.entryPrice, currentPrice),
72
+ });
73
+ }),
74
+ );
75
+ },
76
+ };
backend/src/positions/positions.validators.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { z } from 'zod';
2
+
3
+ export const openBody = z.object({
4
+ marketId: z.string().min(1),
5
+ outcome: z.enum(['YES', 'NO']),
6
+ amountEur: z.number().positive().max(100_000),
7
+ });
8
+
9
+ export const idParam = z.object({
10
+ id: z.coerce.number().int().positive(),
11
+ });
12
+
13
+ export const listQuery = z.object({
14
+ status: z.enum(['open', 'closed']).optional(),
15
+ });
backend/src/routes/alerts.js DELETED
@@ -1,9 +0,0 @@
1
- /**
2
- * Rutas REST para alertas y notificaciones.
3
- *
4
- * Endpoints:
5
- * GET /api/v1/alerts — historial de alertas enviadas (price_threshold, signal_change)
6
- *
7
- * Las alertas se generan automáticamente por el scheduler cada 60 segundos
8
- * y se envían vía Telegram Bot API.
9
- */
 
 
 
 
 
 
 
 
 
 
backend/src/routes/markets.js DELETED
@@ -1,9 +0,0 @@
1
- /**
2
- * Rutas REST para mercados de predicción.
3
- *
4
- * Endpoints:
5
- * GET /api/v1/markets — lista mercados activos (filtrables por category, country, limit)
6
- * GET /api/v1/markets/:id — detalle de un mercado + última señal IA
7
- *
8
- * Delega la obtención de datos al servicio services/polymarket.js.
9
- */
 
 
 
 
 
 
 
 
 
 
backend/src/routes/positions.js DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * Rutas REST para el simulador de posiciones con capital virtual.
3
- *
4
- * Endpoints:
5
- * POST /api/v1/positions — abrir posición simulada (calcula fracción de Kelly)
6
- * GET /api/v1/positions — listar posiciones del usuario con P&L actualizado
7
- * DELETE /api/v1/positions/:id — cerrar posición y calcular resultado final
8
- *
9
- * Usa userId=1 fijo para la demo.
10
- */
 
 
 
 
 
 
 
 
 
 
 
backend/src/routes/signals.js DELETED
@@ -1,9 +0,0 @@
1
- /**
2
- * Rutas REST para señales de inteligencia artificial.
3
- *
4
- * Endpoints:
5
- * GET /api/v1/markets/:id/signal — devuelve la señal IA más reciente del mercado.
6
- *
7
- * Las señales incluyen: signal (bullish|bearish|neutral), confidence (0-1),
8
- * summary, keyRisk y timestamp.
9
- */
 
 
 
 
 
 
 
 
 
 
backend/src/routes/watchlist.js DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * Rutas REST para la watchlist (mercados favoritos).
3
- *
4
- * Endpoints:
5
- * POST /api/v1/watchlist — añadir mercado a favoritos
6
- * DELETE /api/v1/watchlist/:marketId — eliminar mercado de favoritos
7
- * GET /api/v1/watchlist — listar favoritos con precios en vivo
8
- *
9
- * El usuario demo (userId=1) puede gestionar su lista personal.
10
- */
 
 
 
 
 
 
 
 
 
 
 
backend/src/scheduler.js CHANGED
@@ -1,9 +1,61 @@
1
- /**
2
- * Scheduler de tareas periódicas usando node-cron.
3
- *
4
- * Define y ejecuta los 4 jobs principales:
5
- * 1. syncMarkets — cada 30s, sincroniza precios desde Polymarket Gamma API.
6
- * 2. generateSignals — cada 5 min, genera señales IA para el top 20 mercados.
7
- * 3. updatePositionsPnL— cada 30s, recalcula P&L de posiciones abiertas.
8
- * 4. processAlerts — cada 60s, revisa watchlist y envía alertas Telegram.
9
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { schedule } from 'node-cron';
2
+ import { fetchActiveMarkets } from './markets/polymarket.client.js';
3
+ import { marketsRepository } from './markets/markets.repository.js';
4
+ import { signalsService } from './signals/signals.service.js';
5
+ import { positionsService } from './positions/positions.service.js';
6
+ import { alertsService } from './alerts/alerts.service.js';
7
+ import { emitMarketUpdate } from './socket/broadcaster.js';
8
+ import { logger } from './utils/logger.js';
9
+
10
+ async function syncMarkets() {
11
+ try {
12
+ const markets = await fetchActiveMarkets(100);
13
+ await Promise.all(markets.map((m) => marketsRepository.upsert(m)));
14
+ for (const m of markets) {
15
+ emitMarketUpdate({ marketId: m.id, yesPrice: m.yesPrice, noPrice: m.noPrice, volumeEur: m.volumeEur });
16
+ }
17
+ logger.info({ count: markets.length }, 'markets synced');
18
+ } catch (err) {
19
+ logger.error({ err: err.message }, 'syncMarkets failed');
20
+ }
21
+ }
22
+
23
+ async function generateSignals() {
24
+ try {
25
+ const markets = await marketsRepository.findMany({ limit: 20, offset: 0, status: 'active' });
26
+ for (const market of markets) {
27
+ try {
28
+ await signalsService.generateForMarket(market);
29
+ } catch (err) {
30
+ logger.error({ err: err.message, marketId: market.id }, 'signal generation failed for market');
31
+ }
32
+ }
33
+ } catch (err) {
34
+ logger.error({ err: err.message }, 'generateSignals failed');
35
+ }
36
+ }
37
+
38
+ async function updatePositionsPnL() {
39
+ try {
40
+ await positionsService.updateAllPnL();
41
+ } catch (err) {
42
+ logger.error({ err: err.message }, 'updatePositionsPnL failed');
43
+ }
44
+ }
45
+
46
+ async function processAlerts() {
47
+ try {
48
+ await alertsService.processAll();
49
+ } catch (err) {
50
+ logger.error({ err: err.message }, 'processAlerts failed');
51
+ }
52
+ }
53
+
54
+ export function startScheduler() {
55
+ syncMarkets();
56
+ schedule('*/30 * * * * *', syncMarkets);
57
+ schedule('*/5 * * * *', generateSignals);
58
+ schedule('*/30 * * * * *', updatePositionsPnL);
59
+ schedule('* * * * *', processAlerts);
60
+ logger.info('scheduler started');
61
+ }
backend/src/services/aiPipeline.js DELETED
@@ -1,12 +0,0 @@
1
- /**
2
- * Pipeline de inteligencia artificial para generación de señales.
3
- *
4
- * Flujo de dos fases:
5
- * 1. ModernFinBERT (HF Inference API) → filtra noticias con label=neutral o score<0.65
6
- * 2. Qwen3-8B (HF Inference API) → analiza mercado + noticias filtradas y genera:
7
- * { signal, confidence, summary, keyRisk }
8
- *
9
- * Fallback: si HF está saturado, usa OpenRouter con deepseek/deepseek-v4-flash.
10
- *
11
- * Persiste el resultado en la tabla AISignal y emite evento 'ai_signal' por Socket.io.
12
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/services/finnhub.js DELETED
@@ -1,11 +0,0 @@
1
- /**
2
- * Servicio de integración con Finnhub REST API.
3
- *
4
- * Responsabilidades:
5
- * - Obtener titulares de noticias financieras relevantes.
6
- * - Proporcionar noticias crudas al pipeline de IA para análisis de sentimiento.
7
- *
8
- * Restricciones:
9
- * - Free tier: máximo 60 llamadas/minuto.
10
- * - Solo se invoca durante la generación de señales (cada 5 min), no en cada sync de precios.
11
- */
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/services/kelly.js DELETED
@@ -1,15 +0,0 @@
1
- /**
2
- * Servicio de cálculo del Criterio de Kelly.
3
- *
4
- * Fórmula:
5
- * k = (odds * pWin - pLose) / odds
6
- * odds = (1 / yesPrice) - 1
7
- * pLose = 1 - pWin
8
- *
9
- * Donde:
10
- * - pWin = confidence de la señal IA (0.0 a 1.0)
11
- * - yesPrice = precio actual del outcome YES en Polymarket
12
- *
13
- * El resultado se capa en 25% (máximo) y nunca es negativo.
14
- * Se almacena en Position.kellyFraction al abrir una posición.
15
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/services/polymarket.js DELETED
@@ -1,11 +0,0 @@
1
- /**
2
- * Servicio de integración con Polymarket Gamma API.
3
- *
4
- * Responsabilidades:
5
- * - Fetch de mercados activos desde https://gamma-api.polymarket.com
6
- * - Parseo y normalización de campos: id, question, outcomePrices, volume, liquidity, category, endDate
7
- * - Persistencia en la tabla Market vía Prisma
8
- * - Asignación de countryCode por geo-inferencia (delegado al pipeline de IA)
9
- *
10
- * No usa autenticación. No usa CLOB API.
11
- */
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/services/telegramBot.js DELETED
@@ -1,12 +0,0 @@
1
- /**
2
- * Servicio de envío de alertas por Telegram Bot API.
3
- *
4
- * Implementación sin librerías externas: llamada HTTP directa a:
5
- * POST https://api.telegram.org/bot<TOKEN>/sendMessage
6
- *
7
- * Tipos de alerta soportados:
8
- * - price_threshold: un precio de watchlist cruzó el umbral definido.
9
- * - signal_change: la señal IA de un mercado cambió de estado.
10
- *
11
- * El chat_id se obtiene del campo telegramChatId del usuario demo.
12
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/src/signals/aiPipeline.js ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpPost } from '../utils/httpClient.js';
2
+ import { config } from '../config.js';
3
+ import { logger } from '../utils/logger.js';
4
+ import { fetchFinancialNews, filterNewsByRelevance } from './finnhub.client.js';
5
+
6
+ const HF_API = 'https://api-inference.huggingface.co/models';
7
+ const FINBERT_MODEL = 'ProsusAI/finbert';
8
+ const QWEN_MODEL = 'Qwen/Qwen3-8B';
9
+ const OPENROUTER_API = 'https://openrouter.ai/api/v1/chat/completions';
10
+ const OPENROUTER_MODEL = 'deepseek/deepseek-chat-v3-0324:free';
11
+
12
+ // ── helpers ──────────────────────────────────────────────────────────────────
13
+
14
+ function hfHeaders() {
15
+ return { Authorization: `Bearer ${config.HF_TOKEN}` };
16
+ }
17
+
18
+ function extractJson(text) {
19
+ try { return JSON.parse(text); } catch {}
20
+ // strip <think>...</think> blocks (Qwen3 thinking mode)
21
+ const stripped = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
22
+ try { return JSON.parse(stripped); } catch {}
23
+ const block = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
24
+ if (block) { try { return JSON.parse(block[1].trim()); } catch {} }
25
+ const inline = stripped.match(/\{[\s\S]*?\}/);
26
+ if (inline) { try { return JSON.parse(inline[0]); } catch {} }
27
+ return null;
28
+ }
29
+
30
+ function buildPrompt(market, headlines) {
31
+ const newsSection = headlines.length
32
+ ? `Relevant news:\n${headlines.map((h) => `- ${h.headline}`).join('\n')}`
33
+ : 'No relevant news available.';
34
+ return [
35
+ `You are a prediction market analyst. Analyze the following market and respond ONLY with valid JSON.`,
36
+ ``,
37
+ `Market: "${market.question}"`,
38
+ `YES price: ${market.yesPrice ?? 'N/A'} | NO price: ${market.noPrice ?? 'N/A'}`,
39
+ newsSection,
40
+ ``,
41
+ `Return ONLY this JSON structure, no markdown, no explanation:`,
42
+ `{"signal":"bullish"|"bearish"|"neutral","confidence":<0.0-1.0>,"summary":"<2 sentences>","keyRisk":"<1 sentence>"}`,
43
+ ].join('\n');
44
+ }
45
+
46
+ // ── FinBERT filtering ─────────────────────────────────────────────────────────
47
+
48
+ async function filterWithFinBERT(articles) {
49
+ if (!articles.length) return articles;
50
+ const inputs = articles.map((a) => a.headline);
51
+ const results = await httpPost(
52
+ `${HF_API}/${FINBERT_MODEL}`,
53
+ { inputs },
54
+ { headers: hfHeaders(), retries: 1 },
55
+ );
56
+ // keep headlines classified as positive or negative with score >= 0.65
57
+ return articles.filter((_, i) => {
58
+ const top = results[i]?.[0];
59
+ return top && top.label !== 'neutral' && top.score >= 0.65;
60
+ });
61
+ }
62
+
63
+ // ── LLM generation ────────────────────────────────────────────────────────────
64
+
65
+ async function callChatCompletion(url, model, messages, authHeader) {
66
+ const data = await httpPost(
67
+ url,
68
+ { model, messages, max_tokens: 300, temperature: 0.3 },
69
+ { headers: { Authorization: authHeader }, retries: 1, timeout: 30_000 },
70
+ );
71
+ return data?.choices?.[0]?.message?.content ?? null;
72
+ }
73
+
74
+ async function generateWithQwen3(market, headlines) {
75
+ const content = await callChatCompletion(
76
+ `${HF_API}/${QWEN_MODEL}/v1/chat/completions`,
77
+ QWEN_MODEL,
78
+ [{ role: 'user', content: buildPrompt(market, headlines) }],
79
+ `Bearer ${config.HF_TOKEN}`,
80
+ );
81
+ return content ? extractJson(content) : null;
82
+ }
83
+
84
+ async function generateWithOpenRouter(market, headlines) {
85
+ const content = await callChatCompletion(
86
+ OPENROUTER_API,
87
+ OPENROUTER_MODEL,
88
+ [{ role: 'user', content: buildPrompt(market, headlines) }],
89
+ `Bearer ${config.OPENROUTER_API_KEY}`,
90
+ );
91
+ return content ? extractJson(content) : null;
92
+ }
93
+
94
+ // ── rule-based fallback ───────────────────────────────────────────────────────
95
+
96
+ function ruleBasedSignal(market) {
97
+ const p = market.yesPrice ?? 0.5;
98
+ if (p >= 0.65)
99
+ return {
100
+ signal: 'bullish',
101
+ confidence: Math.min(p, 0.9),
102
+ summary: `Market strongly favors YES at ${(p * 100).toFixed(0)}% probability. Momentum is positive.`,
103
+ keyRisk: 'Sentiment shift could rapidly reprice the market.',
104
+ };
105
+ if (p <= 0.35)
106
+ return {
107
+ signal: 'bearish',
108
+ confidence: Math.min(1 - p, 0.9),
109
+ summary: `Market strongly favors NO at ${((1 - p) * 100).toFixed(0)}% probability. Downside momentum dominates.`,
110
+ keyRisk: 'Unexpected positive developments could reverse this rapidly.',
111
+ };
112
+ return {
113
+ signal: 'neutral',
114
+ confidence: 0.5,
115
+ summary: 'Market is closely contested with no clear directional signal. Both outcomes remain plausible.',
116
+ keyRisk: 'High uncertainty in both directions; position sizing should be conservative.',
117
+ };
118
+ }
119
+
120
+ function validateSignal(result) {
121
+ if (!result) return false;
122
+ const validSignals = ['bullish', 'bearish', 'neutral'];
123
+ return (
124
+ validSignals.includes(result.signal) &&
125
+ typeof result.confidence === 'number' &&
126
+ result.confidence >= 0 &&
127
+ result.confidence <= 1 &&
128
+ typeof result.summary === 'string' &&
129
+ typeof result.keyRisk === 'string'
130
+ );
131
+ }
132
+
133
+ // ── public API ────────────────────────────────────────────────────────────────
134
+
135
+ export async function run(market) {
136
+ // Step 1: fetch and filter news
137
+ let headlines = [];
138
+ try {
139
+ const allNews = await fetchFinancialNews(30);
140
+ const relevant = filterNewsByRelevance(allNews, market.question);
141
+ if (config.HF_TOKEN && relevant.length) {
142
+ headlines = await filterWithFinBERT(relevant);
143
+ } else {
144
+ headlines = relevant.slice(0, 10);
145
+ }
146
+ } catch (err) {
147
+ logger.warn({ err: err.message, marketId: market.id }, 'news fetch failed, continuing without news');
148
+ }
149
+
150
+ // Step 2: LLM signal generation with fallback chain
151
+ let result = null;
152
+
153
+ if (config.HF_TOKEN) {
154
+ try {
155
+ result = await generateWithQwen3(market, headlines);
156
+ if (!validateSignal(result)) result = null;
157
+ } catch (err) {
158
+ logger.warn({ err: err.message, marketId: market.id }, 'Qwen3 failed, trying OpenRouter');
159
+ }
160
+ }
161
+
162
+ if (!result && config.OPENROUTER_API_KEY) {
163
+ try {
164
+ result = await generateWithOpenRouter(market, headlines);
165
+ if (!validateSignal(result)) result = null;
166
+ } catch (err) {
167
+ logger.warn({ err: err.message, marketId: market.id }, 'OpenRouter failed, using rule-based');
168
+ }
169
+ }
170
+
171
+ if (!result) {
172
+ result = ruleBasedSignal(market);
173
+ }
174
+
175
+ return { ...result, newsCount: headlines.length };
176
+ }
backend/src/signals/finnhub.client.js ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { httpGet } from '../utils/httpClient.js';
2
+ import { config } from '../config.js';
3
+
4
+ export async function fetchFinancialNews(count = 30) {
5
+ if (!config.FINNHUB_API_KEY) return [];
6
+ const url = `https://finnhub.io/api/v1/news?category=general&token=${config.FINNHUB_API_KEY}`;
7
+ const articles = await httpGet(url);
8
+ return articles.slice(0, count).map((a) => ({
9
+ headline: a.headline ?? '',
10
+ summary: a.summary ?? '',
11
+ }));
12
+ }
13
+
14
+ export function filterNewsByRelevance(articles, question) {
15
+ const keywords = question
16
+ .toLowerCase()
17
+ .split(/\W+/)
18
+ .filter((w) => w.length > 4);
19
+ if (keywords.length === 0) return articles;
20
+ return articles.filter((a) => {
21
+ const text = `${a.headline} ${a.summary}`.toLowerCase();
22
+ return keywords.some((kw) => text.includes(kw));
23
+ });
24
+ }
backend/src/signals/signals.controller.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { ok } from '../utils/apiResponse.js';
2
+ import { signalsService } from './signals.service.js';
3
+
4
+ export const signalsController = {
5
+ async getLatest(req, res) {
6
+ const signal = await signalsService.getLatest(req.params.marketId);
7
+ ok(res, signal);
8
+ },
9
+ };
backend/src/signals/signals.repository.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from '../utils/prisma.js';
2
+
3
+ export const signalsRepository = {
4
+ create({ marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion }) {
5
+ return prisma.aISignal.create({
6
+ data: { marketId, signal, confidence, summary, keyRisk, newsCount, modelVersion },
7
+ });
8
+ },
9
+
10
+ findLatestByMarket(marketId) {
11
+ return prisma.aISignal.findFirst({
12
+ where: { marketId },
13
+ orderBy: { generatedAt: 'desc' },
14
+ });
15
+ },
16
+ };
backend/src/signals/signals.routes.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { signalsController } from './signals.controller.js';
3
+
4
+ const router = Router();
5
+
6
+ // mounted at /api/v1/markets → full path: GET /api/v1/markets/:marketId/signal
7
+ router.get('/:marketId/signal', signalsController.getLatest);
8
+
9
+ export default router;
backend/src/signals/signals.service.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { HttpError } from '../utils/apiResponse.js';
2
+ import { signalsRepository } from './signals.repository.js';
3
+ import { marketsRepository } from '../markets/markets.repository.js';
4
+ import * as aiPipeline from './aiPipeline.js';
5
+ import { emitAiSignal } from '../socket/broadcaster.js';
6
+ import { logger } from '../utils/logger.js';
7
+
8
+ export const signalsService = {
9
+ async getLatest(marketId) {
10
+ const market = await marketsRepository.findById(marketId);
11
+ if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found');
12
+ const signal = await signalsRepository.findLatestByMarket(marketId);
13
+ if (!signal) throw new HttpError(404, 'NOT_FOUND', 'No signal available for this market yet');
14
+ return signal;
15
+ },
16
+
17
+ async generateForMarket(market) {
18
+ const modelVersion = 'Qwen3-8B';
19
+ const result = await aiPipeline.run(market);
20
+ const saved = await signalsRepository.create({ marketId: market.id, modelVersion, ...result });
21
+ emitAiSignal({ marketId: market.id, signal: saved.signal, confidence: saved.confidence, summary: saved.summary });
22
+ logger.info({ marketId: market.id, signal: saved.signal }, 'signal generated');
23
+ return saved;
24
+ },
25
+ };
backend/src/socket/broadcaster.js CHANGED
@@ -1,10 +1,17 @@
1
- /**
2
- * Módulo de difusión de eventos en tiempo real vía Socket.io.
3
- *
4
- * Emite desde el servidor hacia todos los clientes conectados:
5
- * - 'market_update' → precios actualizados de mercados (cada 30s)
6
- * - 'ai_signal' → nueva señal IA generada (cada 5 min)
7
- * - 'price_alert' → alerta de umbral activada (cada 60s)
8
- *
9
- * Se importa y usa en index.js (servidor) y en scheduler.js (jobs).
10
- */
 
 
 
 
 
 
 
 
1
+ let _io = null;
2
+
3
+ export function attachBroadcaster(io) {
4
+ _io = io;
5
+ }
6
+
7
+ export function emitMarketUpdate(payload) {
8
+ _io?.emit('market_update', payload);
9
+ }
10
+
11
+ export function emitAiSignal(payload) {
12
+ _io?.emit('ai_signal', payload);
13
+ }
14
+
15
+ export function emitPriceAlert(payload) {
16
+ _io?.emit('price_alert', payload);
17
+ }
backend/src/utils/httpClient.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { logger } from './logger.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 10_000;
4
+ const DEFAULT_RETRIES = 3;
5
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
6
+
7
+ function backoff(attempt) {
8
+ return Math.min(1_000 * 2 ** attempt, 30_000);
9
+ }
10
+
11
+ async function request(url, init = {}, { timeout = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES } = {}) {
12
+ for (let attempt = 0; attempt <= retries; attempt++) {
13
+ const controller = new AbortController();
14
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
15
+
16
+ try {
17
+ const res = await fetch(url, {
18
+ ...init,
19
+ headers: { 'User-Agent': 'PolySignal/1.0', ...init.headers },
20
+ signal: controller.signal,
21
+ });
22
+
23
+ clearTimeout(timeoutId);
24
+
25
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < retries) {
26
+ const wait = backoff(attempt);
27
+ logger.warn({ url, status: res.status, attempt, wait }, 'retrying request');
28
+ await new Promise((r) => setTimeout(r, wait));
29
+ continue;
30
+ }
31
+
32
+ const text = await res.text();
33
+ const data = text ? JSON.parse(text) : null;
34
+
35
+ if (!res.ok) {
36
+ const err = Object.assign(new Error(`HTTP ${res.status} — ${url}`), {
37
+ status: res.status,
38
+ body: data,
39
+ });
40
+ throw err;
41
+ }
42
+
43
+ return data;
44
+ } catch (err) {
45
+ clearTimeout(timeoutId);
46
+
47
+ if (err.name === 'AbortError') {
48
+ throw Object.assign(new Error(`Timeout after ${timeout}ms — ${url}`), { code: 'TIMEOUT' });
49
+ }
50
+
51
+ // network error (no HTTP status) → retry
52
+ if (!err.status && attempt < retries) {
53
+ const wait = backoff(attempt);
54
+ logger.warn({ url, err: err.message, attempt, wait }, 'network error, retrying');
55
+ await new Promise((r) => setTimeout(r, wait));
56
+ continue;
57
+ }
58
+
59
+ logger.error({ url, err: err.message }, 'request failed');
60
+ throw err;
61
+ }
62
+ }
63
+ }
64
+
65
+ export const httpGet = (url, { headers, ...opts } = {}) =>
66
+ request(url, { method: 'GET', headers }, opts);
67
+
68
+ export const httpPost = (url, body, { headers, ...opts } = {}) =>
69
+ request(
70
+ url,
71
+ { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) },
72
+ opts,
73
+ );
backend/src/watchlist/watchlist.controller.js ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ok, created, noContent } from '../utils/apiResponse.js';
2
+ import { watchlistService } from './watchlist.service.js';
3
+
4
+ export const watchlistController = {
5
+ async add(req, res) {
6
+ const entry = await watchlistService.add(req.user.id, req.body);
7
+ created(res, entry);
8
+ },
9
+
10
+ async remove(req, res) {
11
+ await watchlistService.remove(req.user.id, req.params.marketId);
12
+ noContent(res);
13
+ },
14
+
15
+ async list(req, res) {
16
+ const entries = await watchlistService.list(req.user.id);
17
+ ok(res, entries);
18
+ },
19
+ };
backend/src/watchlist/watchlist.repository.js ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { prisma } from '../utils/prisma.js';
2
+
3
+ export const watchlistRepository = {
4
+ create({ userId, marketId, alertThreshold }) {
5
+ return prisma.watchlist.create({ data: { userId, marketId, alertThreshold } });
6
+ },
7
+
8
+ findByUser(userId) {
9
+ return prisma.watchlist.findMany({
10
+ where: { userId },
11
+ include: {
12
+ market: { select: { id: true, question: true, yesPrice: true, noPrice: true, status: true } },
13
+ },
14
+ orderBy: { createdAt: 'desc' },
15
+ });
16
+ },
17
+
18
+ deleteByUserAndMarket(userId, marketId) {
19
+ return prisma.watchlist.deleteMany({ where: { userId, marketId } });
20
+ },
21
+
22
+ findAllWithThreshold() {
23
+ return prisma.watchlist.findMany({
24
+ where: { alertThreshold: { not: null } },
25
+ include: {
26
+ user: { select: { id: true, telegramChatId: true } },
27
+ market: { select: { id: true, question: true, yesPrice: true } },
28
+ },
29
+ });
30
+ },
31
+ };
backend/src/watchlist/watchlist.routes.js ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Router } from 'express';
2
+ import { validate } from '../middlewares/validate.js';
3
+ import { requireAuth } from '../middlewares/requireAuth.js';
4
+ import { watchlistController } from './watchlist.controller.js';
5
+ import { addBody, marketIdParam } from './watchlist.validators.js';
6
+
7
+ const router = Router();
8
+
9
+ router.use(requireAuth);
10
+
11
+ router.post('/', validate(addBody), watchlistController.add);
12
+ router.get('/', watchlistController.list);
13
+ router.delete('/:marketId', validate(marketIdParam, 'params'), watchlistController.remove);
14
+
15
+ export default router;