germansango01 commited on
Commit ·
bce154c
1
Parent(s): b6ee67e
flow completo backend polymarket y socket.io implementado
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- backend/docs/ALERTS.md +129 -0
- backend/docs/API.md +137 -0
- backend/docs/MARKETS.md +193 -0
- backend/docs/POSITIONS.md +196 -0
- backend/docs/SIGNALS.md +128 -0
- backend/docs/WATCHLIST.md +157 -0
- backend/docs/insomnia-collection.json +206 -0
- backend/src/alerts/alerts.controller.js +9 -0
- backend/src/alerts/alerts.repository.js +23 -0
- backend/src/alerts/alerts.routes.js +11 -0
- backend/src/alerts/alerts.service.js +42 -0
- backend/src/alerts/telegram.client.js +16 -0
- backend/src/app.js +20 -0
- backend/src/config.js +4 -0
- backend/src/index.js +9 -0
- backend/src/markets/markets.controller.js +15 -0
- backend/src/markets/markets.repository.js +32 -0
- backend/src/markets/markets.routes.js +11 -0
- backend/src/markets/markets.service.js +18 -0
- backend/src/markets/markets.validators.js +12 -0
- backend/src/markets/polymarket.client.js +45 -0
- backend/src/middlewares/validate.js +6 -1
- backend/src/positions/kelly.js +12 -0
- backend/src/positions/positions.controller.js +19 -0
- backend/src/positions/positions.repository.js +30 -0
- backend/src/positions/positions.routes.js +15 -0
- backend/src/positions/positions.service.js +76 -0
- backend/src/positions/positions.validators.js +15 -0
- backend/src/routes/alerts.js +0 -9
- backend/src/routes/markets.js +0 -9
- backend/src/routes/positions.js +0 -10
- backend/src/routes/signals.js +0 -9
- backend/src/routes/watchlist.js +0 -10
- backend/src/scheduler.js +61 -9
- backend/src/services/aiPipeline.js +0 -12
- backend/src/services/finnhub.js +0 -11
- backend/src/services/kelly.js +0 -15
- backend/src/services/polymarket.js +0 -11
- backend/src/services/telegramBot.js +0 -12
- backend/src/signals/aiPipeline.js +176 -0
- backend/src/signals/finnhub.client.js +24 -0
- backend/src/signals/signals.controller.js +9 -0
- backend/src/signals/signals.repository.js +16 -0
- backend/src/signals/signals.routes.js +9 -0
- backend/src/signals/signals.service.js +25 -0
- backend/src/socket/broadcaster.js +17 -10
- backend/src/utils/httpClient.js +73 -0
- backend/src/watchlist/watchlist.controller.js +19 -0
- backend/src/watchlist/watchlist.repository.js +31 -0
- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 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;
|