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