/**
* Logica principal de la SPA PolySignal (Single Page Application).
*
* Responsabilidades:
* - Routing de vistas: dashboard, positions, watchlist, alerts.
* - Sidebar colapsable y paneles del dashboard plegables individualmente.
* - Carga inicial de datos desde la API REST (mercados, senales, posiciones, watchlist, alertas).
* - Actualizaciones en tiempo real via Socket.io:
* * market_update → refresca precios, volumen y mapa.
* * ai_signal → actualiza badges de senal IA en el panel.
* * price_alert → anade alertas al historial.
* - Renderizado del panel de detalle de mercado con sparklines, grafico 7d,
* analisis IA y simulador de posiciones virtuales.
* - Auto-login con credenciales demo para endpoints protegidos.
*
* Modulos importados:
* - api.js → cliente REST del backend (con JWT).
* - charts.js → Chart.js (historial 7d + sparklines).
* - map.js → Leaflet (mapa mundial interactivo).
* - simulator.js → logica de compra/venta virtual.
*
* Seguridad del frontend:
* - Todo el DOM se construye con document.createElement() + textContent.
* - Nunca se usa innerHTML con datos externos (mitiga XSS).
* - Socket.io valida tipos de datos antes de actualizar el estado.
*/
import { io } from 'socket.io-client'
import * as api from './api.js'
import * as charts from './charts.js'
import * as map from './map.js'
import * as simulator from './simulator.js'
import { extractFilterOptions, filterMarkets } from './filters.js'
/* ─── Estado global ─── */
let state = {
view: 'dashboard',
activeMarketId: null,
markets: [],
signals: [],
positions: [],
watchlist: [],
alerts: [],
collapsedPanels: new Set(),
sidebarCollapsed: false,
signalsOffset: 0,
signalsHasMore: true,
signalsLoading: false,
filters: { category: '', trend: '' },
priceHistory: new Map(), // marketId → [{price, timestamp}]
}
let signalsObserver = null
/* ─── Helpers ─── */
function formatCurrency(n) {
if (!n || n === 0) return '€0'
if (n >= 1e6) return '€' + (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return '€' + (n / 1e3).toFixed(1) + 'K'
return '€' + n.toFixed(0)
}
function formatPrice(p) {
return Math.round((p || 0) * 100) + '¢'
}
function formatDate(iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('es-ES', { month: 'short', day: 'numeric', year: 'numeric' })
}
function signalColorClass(signal) {
if (signal === 'bullish') return 'green'
if (signal === 'bearish') return 'red'
return 'amber'
}
function getSignalBadgeClass(signal) {
if (signal === 'bullish') return 'sig-bull'
if (signal === 'bearish') return 'sig-bear'
return 'sig-neut'
}
function getSignalLabel(signal) {
if (signal === 'bullish') return 'ALC'
if (signal === 'bearish') return 'BAJ'
return 'NEUT'
}
function translateSignal(signal) {
if (signal === 'bullish') return 'alcista'
if (signal === 'bearish') return 'bajista'
return 'neutral'
}
function abbrevSignal(signal) {
if (signal === 'bullish') return 'A'
if (signal === 'bearish') return 'B'
return 'N'
}
function translateOutcome(outcome) {
if (outcome === 'YES') return 'SÍ'
if (outcome === 'NO') return 'NO'
return outcome
}
// Crea un elemento con una clase opcional y contenido de texto opcional
function el(tag, className, text) {
const node = document.createElement(tag)
if (className) node.className = className
if (text !== undefined) node.textContent = text
return node
}
function emptyState(text, sm = false) {
return el('div', sm ? 'empty-state empty-state-sm' : 'empty-state', text)
}
/* ─── Filters ─── */
function populateFilters() {
const opts = extractFilterOptions(state.markets)
const catSel = document.getElementById('filter-category')
if (!catSel) return
const currentCat = catSel.value
catSel.innerHTML = '' +
opts.categories.slice(1).map((c) => ``).join('')
catSel.value = opts.categories.includes(currentCat) ? currentCat : ''
}
function applyFilters() {
state.filters.category = document.getElementById('filter-category')?.value || ''
state.filters.trend = document.getElementById('filter-trend')?.value || ''
let filtered = filterMarkets(state.markets, state.filters)
if (state.filters.trend) {
filtered = filterByTrend(filtered, state.filters.trend)
}
renderSignalsFiltered(filtered)
map.updateMarkers(filtered, state.signals)
}
function initFilters() {
populateFilters()
document.getElementById('filter-category')?.addEventListener('change', applyFilters)
document.getElementById('filter-trend')?.addEventListener('change', applyFilters)
}
/* ─── Trend Tracking ─── */
function recordPrice(marketId, price) {
if (!state.priceHistory.has(marketId)) {
state.priceHistory.set(marketId, [])
}
const history = state.priceHistory.get(marketId)
history.push({ price, timestamp: Date.now() })
// Mantener solo últimos 20 registros (~10 min con sync cada 30s)
if (history.length > 20) history.shift()
}
function getMarketTrend(marketId) {
const history = state.priceHistory.get(marketId)
if (!history || history.length < 2) return { momentum: 0, volatility: 0, avgVolume: 0 }
const prices = history.map((h) => h.price)
const first = prices[0]
const last = prices[prices.length - 1]
const momentum = first !== 0 ? ((last - first) / first) * 100 : 0
// Volatilidad = desviación estándar de cambios porcentuales
let volatility = 0
if (prices.length >= 3) {
const changes = []
for (let i = 1; i < prices.length; i++) {
if (prices[i - 1] !== 0) {
changes.push(((prices[i] - prices[i - 1]) / prices[i - 1]) * 100)
}
}
if (changes.length > 1) {
const mean = changes.reduce((a, b) => a + b, 0) / changes.length
const variance = changes.reduce((sum, c) => sum + Math.pow(c - mean, 2), 0) / changes.length
volatility = Math.sqrt(variance)
}
}
return { momentum, volatility }
}
function filterByTrend(markets, trendType) {
if (!trendType) return markets
// Precalcular trends
const withTrend = markets.map((m) => {
const trend = getMarketTrend(m.id)
const sig = state.signals.find((s) => s.marketId === m.id)
return {
market: m,
momentum: trend.momentum,
volatility: trend.volatility,
volume: m.volumeEur || 0,
signal: sig?.signal || 'neutral',
confidence: sig?.confidence || 0.5,
}
})
switch (trendType) {
case 'hot':
// Más activos = mayor volumen + algún movimiento reciente
return withTrend
.filter((w) => w.volume > 100000 || Math.abs(w.momentum) > 1)
.sort((a, b) => b.volume - a.volume)
.map((w) => w.market)
case 'bullish-trend':
// Tendencia alcista = momentum positivo + señal bullish
return withTrend
.filter((w) => w.momentum > 0.5 || w.signal === 'bullish')
.sort((a, b) => b.momentum - a.momentum)
.map((w) => w.market)
case 'bearish-trend':
// Tendencia bajista = momentum negativo + señal bearish
return withTrend
.filter((w) => w.momentum < -0.5 || w.signal === 'bearish')
.sort((a, b) => a.momentum - b.momentum)
.map((w) => w.market)
case 'volatile':
// Más volátiles = mayor desviación estándar de cambios
return withTrend
.filter((w) => w.volatility > 0.3)
.sort((a, b) => b.volatility - a.volatility)
.map((w) => w.market)
case 'high-volume':
// Alto volumen
return withTrend
.filter((w) => w.volume > 500000)
.sort((a, b) => b.volume - a.volume)
.map((w) => w.market)
default:
return markets
}
}
/* ─── Auth Modal ─── */
function openAuthModal() {
document.getElementById('auth-modal')?.classList.remove('hidden')
}
function closeAuthModal() {
document.getElementById('auth-modal')?.classList.add('hidden')
const loginError = document.getElementById('login-error')
const registerError = document.getElementById('register-error')
if (loginError) loginError.textContent = ''
if (registerError) registerError.textContent = ''
}
function switchAuthTab(tab) {
document.querySelectorAll('.modal-tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab))
document.querySelectorAll('.modal-form').forEach((f) => f.classList.toggle('active', f.id === `form-${tab}`))
const loginError = document.getElementById('login-error')
const registerError = document.getElementById('register-error')
if (loginError) loginError.textContent = ''
if (registerError) registerError.textContent = ''
}
/* ─── Telegram Modal ─── */
function openTelegramModal() {
const modal = document.getElementById('telegram-modal')
if (!modal) return
// Load saved settings from localStorage
const saved = JSON.parse(localStorage.getItem('telegramConfig') || '{}')
document.getElementById('telegram-bot-token').value = saved.botToken || ''
document.getElementById('telegram-chat-id').value = saved.chatId || ''
document.getElementById('telegram-enabled').checked = saved.enabled || false
const statusEl = document.getElementById('telegram-status')
if (statusEl) {
statusEl.textContent = ''
statusEl.className = 'form-status'
}
modal.classList.remove('hidden')
}
function closeTelegramModal() {
document.getElementById('telegram-modal')?.classList.add('hidden')
const statusEl = document.getElementById('telegram-status')
if (statusEl) {
statusEl.textContent = ''
statusEl.className = 'form-status'
}
}
function handleTelegramSave(e) {
e.preventDefault()
const botToken = document.getElementById('telegram-bot-token').value.trim()
const chatId = document.getElementById('telegram-chat-id').value.trim()
const enabled = document.getElementById('telegram-enabled').checked
const statusEl = document.getElementById('telegram-status')
if (enabled && (!botToken || !chatId)) {
statusEl.textContent = 'Completa token y chat ID para activar alertas.'
statusEl.className = 'form-status error'
return
}
localStorage.setItem('telegramConfig', JSON.stringify({ botToken, chatId, enabled }))
statusEl.textContent = 'Configuración guardada correctamente.'
statusEl.className = 'form-status success'
setTimeout(() => closeTelegramModal(), 1200)
}
function handleTelegramTest() {
const botToken = document.getElementById('telegram-bot-token').value.trim()
const chatId = document.getElementById('telegram-chat-id').value.trim()
const statusEl = document.getElementById('telegram-status')
if (!botToken || !chatId) {
statusEl.textContent = 'Introduce token y chat ID antes de probar.'
statusEl.className = 'form-status error'
return
}
statusEl.textContent = 'Enviando mensaje de prueba…'
statusEl.className = 'form-status'
// Real call to Telegram Bot API
fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: '🧪 PolySignal — Mensaje de prueba\n\nLas alertas de Telegram están configuradas correctamente.',
}),
})
.then((res) => res.json())
.then((data) => {
if (data.ok) {
statusEl.textContent = 'Mensaje de prueba enviado. Revisa Telegram.'
statusEl.className = 'form-status success'
} else {
statusEl.textContent = `Error de Telegram: ${data.description || 'verifica token y chat ID'}`
statusEl.className = 'form-status error'
}
})
.catch(() => {
statusEl.textContent = 'No se pudo conectar con Telegram. Revisa tu conexión.'
statusEl.className = 'form-status error'
})
}
function updateAuthButton() {
const btn = document.getElementById('btn-auth')
const indicator = document.getElementById('btn-auth-mobile')
const authed = api.isAuthenticated()
if (btn) {
if (authed) {
btn.textContent = 'Salir'
btn.onclick = () => {
api.logout()
updateAuthButton()
location.reload()
}
} else {
btn.textContent = 'Entrar'
btn.onclick = openAuthModal
}
}
if (indicator) {
indicator.classList.toggle('logged-in', authed)
indicator.title = authed ? 'Salir' : 'Entrar'
indicator.onclick = authed
? () => {
api.logout()
updateAuthButton()
location.reload()
}
: openAuthModal
}
}
async function handleLogin(e) {
e.preventDefault()
const email = document.getElementById('login-email').value.trim()
const password = document.getElementById('login-password').value
const errorEl = document.getElementById('login-error')
try {
await api.login(email, password)
closeAuthModal()
updateAuthButton()
await initAppData()
} catch (err) {
errorEl.textContent = 'Credenciales incorrectas. Inténtalo de nuevo.'
}
}
async function handleRegister(e) {
e.preventDefault()
const email = document.getElementById('register-email').value.trim()
const password = document.getElementById('register-password').value
const confirm = document.getElementById('register-password-confirm').value
const errorEl = document.getElementById('register-error')
if (password !== confirm) {
errorEl.textContent = 'Las contraseñas no coinciden.'
return
}
if (password.length < 8) {
errorEl.textContent = 'La contraseña debe tener al menos 8 caracteres.'
return
}
try {
await api.register(email, password)
closeAuthModal()
updateAuthButton()
await initAppData()
} catch (err) {
errorEl.textContent = 'Error al registrar. El correo podría estar en uso.'
}
}
async function ensureAuth() {
if (!api.isAuthenticated()) return false
try {
await api.getMe()
return true
} catch (e) {
// Token inválido o expirado — ya fue borrado por fetchJson
return false
}
}
/* ─── Routing de vistas ─── */
function switchView(viewName) {
state.view = viewName
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${viewName}`))
document.querySelectorAll('.nav-item').forEach((v) => v.classList.toggle('active', v.dataset.view === viewName))
if (viewName === 'positions') renderPositions()
if (viewName === 'watchlist') renderWatchlist()
if (viewName === 'alerts') renderAlerts()
}
/* ─── Sidebar toggle ─── */
function toggleSidebar() {
state.sidebarCollapsed = !state.sidebarCollapsed
document.getElementById('app').classList.toggle('collapsed', state.sidebarCollapsed)
}
/* ─── Panel toggle ─── */
function togglePanel(panelId) {
const panel = document.getElementById(`panel-${panelId}`)
if (!panel) return
const isCollapsed = panel.classList.toggle('collapsed')
if (isCollapsed) state.collapsedPanels.add(panelId)
else state.collapsedPanels.delete(panelId)
}
/* ─── Signal card factory ─── */
function makeSignalCard(m) {
const sig = state.signals.find((s) => s.marketId === m.id) || null
const hasSignal = sig != null
const cls = signalColorClass(sig?.signal || 'neutral')
const card = el('div', `market-card${state.activeMarketId === m.id ? ' active' : ''}`)
card.dataset.market = m.id
const cat = el('div', 'market-cat')
const catLabel = `${m.category || 'General'} · ${m.countryCode || 'GL'}`
cat.textContent = catLabel
// Spread badge si Polymarket reporta uno wide
if (m.spread != null && m.spread > 0.05) {
const spreadBadge = el('span', 'spread-badge illiquid')
spreadBadge.textContent = `· ilíquido ${Math.round(m.spread * 100)}¢`
cat.appendChild(spreadBadge)
} else if (m.spread != null && m.spread > 0.02) {
const spreadBadge = el('span', 'spread-badge')
spreadBadge.textContent = `· spread ${Math.round(m.spread * 100)}¢`
cat.appendChild(spreadBadge)
}
const q = el('div', 'market-q')
q.textContent = m.question
const footer = el('div', 'market-footer')
const probWrap = el('div', 'prob-bar-wrap')
const probBg = el('div', 'prob-bar-bg')
const probFill = el('div', `prob-bar-fill bg-${cls}`)
probFill.style.setProperty('--prob-width', `${Math.round((m.yesPrice || 0) * 100)}%`)
probBg.appendChild(probFill)
probWrap.appendChild(probBg)
const probVal = el('span', `prob-val text-${cls}`)
probVal.textContent = formatPrice(m.yesPrice)
if (hasSignal) {
const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
badge.textContent = getSignalLabel(sig.signal)
const trend = getMarketTrend(m.id)
if (Math.abs(trend.momentum) > 2 || trend.volatility > 1) {
let trendBadgeClass = 'trend-volatile'
let trendText = '⚡'
if (trend.momentum > 3) { trendBadgeClass = 'trend-bull'; trendText = '▲' }
else if (trend.momentum < -3) { trendBadgeClass = 'trend-bear'; trendText = '▼' }
else if (trend.volatility > 1.5) { trendBadgeClass = 'trend-volatile'; trendText = '⚡' }
const trendBadge = el('span', `trend-badge ${trendBadgeClass}`)
trendBadge.textContent = trendText
footer.append(probWrap, probVal, badge, trendBadge)
} else {
footer.append(probWrap, probVal, badge)
}
} else {
const placeholder = el('span', 'signal-badge sig-none')
placeholder.textContent = m.analyzable === false ? 'FUERA DE ALCANCE' : 'SIN ANÁLISIS'
placeholder.title = m.analyzable === false
? 'La IA no puede aportar edge en este tipo de mercado (deportes, predicciones de palabras, etc.).'
: 'Aún no se ha generado una señal para este mercado.'
footer.append(probWrap, probVal, placeholder)
}
card.append(cat, q, footer)
// Edge row: Mercado vs IA con barra de comparacion
if (hasSignal && sig.impliedProb != null && sig.fairProb != null) {
const edgeRow = el('div', 'edge-row')
const edgePts = sig.edgePoints ?? 0
const edgeAbs = Math.abs(edgePts)
const edgeDir = edgePts > 0 ? 'pos' : edgePts < 0 ? 'neg' : 'zero'
const impliedSpan = el('span', 'edge-implied')
impliedSpan.textContent = `Mercado ${Math.round(sig.impliedProb * 100)}%`
const sep1 = el('span', 'edge-sep', '·')
const fairSpan = el('span', `edge-fair text-${cls}`)
fairSpan.textContent = `IA ${Math.round(sig.fairProb * 100)}%`
const sep2 = el('span', 'edge-sep', '·')
const edgeSpan = el('span', `edge-value edge-${edgeDir}`)
const sign = edgePts > 0 ? '+' : edgePts < 0 ? '−' : ''
edgeSpan.textContent = `Edge ${sign}${edgeAbs.toFixed(1)}pp`
edgeRow.append(impliedSpan, sep1, fairSpan, sep2, edgeSpan)
card.append(edgeRow)
}
card.addEventListener('click', () => selectMarket(card.dataset.market))
return card
}
function attachSignalsObserver(container) {
if (signalsObserver) signalsObserver.disconnect()
const sentinel = el('div', 'signals-sentinel', 'Cargando…')
container.appendChild(sentinel)
const isMobile = window.matchMedia('(max-width: 640px)').matches
const panelBody = container.closest('.panel-body')
const root = isMobile ? null : panelBody
signalsObserver = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMoreMarkets() },
{ root, threshold: 0.1 },
)
signalsObserver.observe(sentinel)
}
/* ─── Render signals list ─── */
function renderSignals() {
const filtered = filterMarkets(state.markets, state.filters)
renderSignalsFiltered(filtered)
}
function renderSignalsFiltered(marketsToRender) {
const container = document.getElementById('signals-list')
if (!container) return
if (signalsObserver) { signalsObserver.disconnect(); signalsObserver = null }
container.replaceChildren()
if (marketsToRender.length === 0) {
container.appendChild(emptyState('No hay mercados que coincidan con los filtros'))
return
}
marketsToRender.forEach((m) => container.appendChild(makeSignalCard(m)))
}
function appendSignalCards(newMarkets) {
const container = document.getElementById('signals-list')
if (!container) return
container.querySelector('.signals-sentinel')?.remove()
if (signalsObserver) { signalsObserver.disconnect(); signalsObserver = null }
newMarkets.forEach((m) => container.appendChild(makeSignalCard(m)))
if (state.signalsHasMore) attachSignalsObserver(container)
}
/* ─── Render mini positions in sidebar ─── */
function renderMiniPositions() {
const container = document.getElementById('mini-positions')
if (!container) return
if (state.positions.length === 0) {
container.replaceChildren(emptyState('Aún sin posiciones', true))
return
}
container.replaceChildren()
let netPnl = 0
state.positions.forEach((p) => {
const m = state.markets.find((x) => x.id === p.marketId) || p.market || { question: p.marketId }
netPnl += p.pnl || 0
const cls = (p.pnl || 0) >= 0 ? 'green' : 'red'
const sign = (p.pnl || 0) >= 0 ? '+' : ''
const row = el('div', 'flex-between mb-6')
const label = el('span', 'text-sm text-neutral font-mono')
label.textContent = `${(m.question || p.marketId).substring(0, 32)}${(m.question || p.marketId).length > 32 ? '…' : ''} ${translateOutcome(p.outcome)}`
const val = el('span', `text-base font-semibold text-${cls} font-mono`)
val.textContent = `${sign}€${(p.pnl || 0).toFixed(2)}`
row.append(label, val)
container.appendChild(row)
})
const netCls = netPnl >= 0 ? 'green' : 'red'
const netSign = netPnl >= 0 ? '+' : ''
const netRow = el('div', 'flex-between')
const netLabel = el('span', 'text-sm text-neutral font-mono', 'G&P Neto')
const netVal = el('span', `text-lg font-bold text-${netCls} font-mono`)
netVal.textContent = `${netSign}€${netPnl.toFixed(2)}`
netRow.append(netLabel, netVal)
container.append(el('div', 'divider'), netRow)
}
/* ─── Build detail DOM (shared between desktop panel and mobile inline) ─── */
function buildDetailDOM(m, sig, prefix = '') {
const chartId = `${prefix}detail-chart`
const sparkYesId = `${prefix}spark-yes`
const sparkNoId = `${prefix}spark-no`
const delta = ((m.yesPrice - 0.5) * 20).toFixed(1)
const deltaCls = (m.yesPrice || 0) > 0.5 ? 'green' : 'red'
const deltaSign = (m.yesPrice || 0) > 0.5 ? '+' : ''
// ── Header ──
const tag = el('div', 'detail-tag')
tag.textContent = `${m.countryCode || 'GL'} · ${m.category || 'General'} · Polymarket`
const q = el('div', 'detail-q')
q.textContent = m.question
const meta = el('div', 'detail-meta')
meta.textContent = `Vol: ${formatCurrency(m.volumeEur || 0)} · Liq: ${formatCurrency(m.liquidityEur || 0)} · Cierra: ${formatDate(m.closesAt)}`
const headerLeft = el('div')
headerLeft.append(tag, q, meta)
const deltaEl = el('div', `metric-value text-${deltaCls}`)
deltaEl.textContent = `${deltaSign}${delta}%`
const metricDelta = el('div', 'metric')
const deltaLabel = el('div', 'metric-label')
deltaLabel.append(el('span', 'badge-full', 'Cambio 24h'), el('span', 'badge-abbr', '24h'))
metricDelta.append(deltaLabel, deltaEl)
const confEl = el('div', 'metric-value text-blue')
confEl.textContent = `${Math.round(sig.confidence * 100)}%`
const metricConf = el('div', 'metric')
metricConf.append(el('div', 'metric-label', 'Confianza'), confEl)
// ── SÍ / NO mini chips in header ──
const yesMiniPrice = el('div', 'outcome-mini-price yes')
yesMiniPrice.textContent = formatPrice(m.yesPrice)
const yesMini = el('div', 'outcome-mini')
yesMini.append(el('div', 'outcome-mini-label', 'SÍ'), yesMiniPrice)
const noMiniPrice = el('div', 'outcome-mini-price no')
noMiniPrice.textContent = formatPrice(m.noPrice)
const noMini = el('div', 'outcome-mini')
noMini.append(el('div', 'outcome-mini-label', 'NO'), noMiniPrice)
const metrics = el('div', 'detail-metrics')
metrics.append(yesMini, noMini, el('div', 'metric-sep'), metricDelta, el('div', 'metric-sep'), metricConf)
const header = el('div', 'detail-header')
header.append(headerLeft, metrics)
// ── Outcomes row: AI box + chart ──
const detailChart = el('canvas')
detailChart.id = chartId
const chartContainer = el('div', 'chart-container')
chartContainer.append(el('div', 'chart-label', 'Historial de precios 7d'), detailChart)
const outcomesRow = el('div', 'outcomes-row')
// ── AI box ──
const aiBadge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
const conf = Math.round(sig.confidence * 100)
aiBadge.append(
el('span', 'badge-full', `${translateSignal(sig.signal).toUpperCase()} · ${conf}%`),
el('span', 'badge-abbr', `${abbrevSignal(sig.signal)} · ${conf}%`),
)
const modelBadge = el('span', 'model-badge')
modelBadge.textContent = sig.modelVersion || 'IA'
const aiTitleGroup = el('div', 'ai-title-group')
aiTitleGroup.append(el('div', 'ai-icon', '◈'), el('div', 'ai-label', 'Análisis IA'), modelBadge)
const aiHeader = el('div', 'flex-between mb-4')
aiHeader.append(aiTitleGroup, aiBadge)
// Texto IA construido con nodos DOM — ninguna cadena externa toca innerHTML
const aiText = el('div', 'ai-text')
aiText.textContent = sig.summary || 'Aún no hay análisis de IA disponible.'
if (sig.keyRisk) {
const strong = document.createElement('strong')
strong.textContent = 'Riesgo clave:'
aiText.append(' ', strong, ' ', sig.keyRisk)
}
const aiBox = el('div', 'ai-box')
aiBox.append(aiHeader, aiText)
outcomesRow.append(aiBox, chartContainer)
// ── Simulator row ──
// El backend calcula sizing (Kelly cost-aware con spread). Aqui solo lo pintamos.
const simAmount = el('input', 'sim-input')
simAmount.type = 'number'
simAmount.value = '100'
simAmount.min = '1'
simAmount.placeholder = '€'
const simYes = el('button', 'sim-btn-yes', 'COMPRAR SÍ ↗')
const simNo = el('button', 'sim-btn-no', 'COMPRAR NO')
const noteRow = el('div', 'kelly-note')
noteRow.textContent = 'Calculando sugerencia…'
const simRow = el('div', 'sim-row')
simRow.append(
noteRow,
el('span', 'sim-label', 'Simular posición →'),
simAmount,
simYes,
simNo,
)
simYes.addEventListener('click', () => simulator.openPosition(m.id, 'YES', simAmount.value))
simNo.addEventListener('click', () => simulator.openPosition(m.id, 'NO', simAmount.value))
const content = el('div')
content.append(header, outcomesRow, simRow)
// Fetch async: el backend conoce spread + ultima senal + Kelly conservador
api.getPositionSuggestion(m.id).then((sug) => {
if (!sug) return
if (sug.illiquid) {
simYes.disabled = true
simNo.disabled = true
simYes.title = `Mercado ilíquido (spread ${Math.round((m.spread ?? 0) * 100)}¢).`
simNo.title = simYes.title
noteRow.classList.add('kelly-warn')
}
if (sug.amountEur > 0) {
simAmount.value = String(sug.amountEur)
}
noteRow.textContent = sug.note || ''
}).catch(() => {
noteRow.textContent = ''
})
return { content, chartId, sparkYesId, sparkNoId }
}
/* ─── Render detail panel (desktop) ─── */
function renderDetail() {
const container = document.getElementById('detail-body')
if (!container) return
const m = state.markets.find((x) => x.id === state.activeMarketId)
if (!m) {
container.replaceChildren()
return
}
const sig = state.signals.find((s) => s.marketId === m.id) || {
signal: 'neutral',
confidence: 0.5,
summary: 'Aún no hay análisis de IA disponible.',
keyRisk: '',
}
const { content, chartId, sparkYesId, sparkNoId } = buildDetailDOM(m, sig)
container.replaceChildren(content)
api.getMarketHistory(m.id).then((history) => {
charts.renderDetailChart(chartId, m.yesPrice, history)
}).catch(() => {
charts.renderDetailChart(chartId, m.yesPrice)
})
charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
charts.renderSparkline(sparkNoId, m.noPrice, 'no')
}
/* ─── Select market ─── */
function selectMarket(marketId) {
const isMobile = window.matchMedia('(max-width: 640px)').matches
if (isMobile) {
// Collapse any open inline detail and clear active state
document.querySelectorAll('.market-card-detail').forEach((d) => d.remove())
document.querySelectorAll('#signals-list .market-card.active').forEach((c) => c.classList.remove('active'))
// Toggle off if same card clicked again
if (state.activeMarketId === marketId) {
state.activeMarketId = null
return
}
state.activeMarketId = marketId
map.highlightMarket(marketId)
const card = document.querySelector(`#signals-list .market-card[data-market="${CSS.escape(marketId)}"]`)
if (!card) return
card.classList.add('active')
const m = state.markets.find((x) => x.id === marketId)
if (!m) return
const sig = state.signals.find((s) => s.marketId === marketId) || {
signal: 'neutral',
confidence: 0.5,
summary: 'Aún no hay análisis de IA disponible.',
keyRisk: '',
}
const { content, chartId, sparkYesId, sparkNoId } = buildDetailDOM(m, sig, 'inline-')
const wrapper = el('div', 'market-card-detail')
wrapper.appendChild(content)
card.after(wrapper)
// Charts require the canvas to be in the DOM before rendering.
// Scroll after paint so the layout height is settled.
requestAnimationFrame(() => {
api.getMarketHistory(m.id).then((history) => {
charts.renderDetailChart(chartId, m.yesPrice, history)
}).catch(() => {
charts.renderDetailChart(chartId, m.yesPrice)
})
charts.renderSparkline(sparkYesId, m.yesPrice, 'yes')
charts.renderSparkline(sparkNoId, m.noPrice, 'no')
// Scroll .main so the detail wrapper's top sits just below the sticky signals header,
// pushing the clicked card out of the viewport
const main = document.querySelector('.main')
const stickyHeader = document.querySelector('#panel-signals .panel-header')
if (main) {
const stickyH = stickyHeader ? stickyHeader.offsetHeight : 0
const mainRect = main.getBoundingClientRect()
const wrapperTop = wrapper.getBoundingClientRect().top - mainRect.top + main.scrollTop - stickyH
main.scrollTo({ top: wrapperTop, behavior: 'smooth' })
}
})
} else {
state.activeMarketId = marketId
renderSignals()
renderDetail()
map.highlightMarket(marketId)
}
}
/* ─── Render positions view ─── */
function renderPositions() {
const tbody = document.querySelector('#positions-table tbody')
const empty = document.getElementById('positions-empty')
if (!tbody) return
if (state.positions.length === 0) {
tbody.replaceChildren()
empty.classList.remove('hidden')
return
}
empty.classList.add('hidden')
tbody.replaceChildren()
state.positions.forEach((p) => {
const m = state.markets.find((x) => x.id === p.marketId) || p.market || { question: p.marketId }
const pnlColor = (p.pnl || 0) >= 0 ? 'td-green' : 'td-red'
const sign = (p.pnl || 0) >= 0 ? '+' : ''
const tr = document.createElement('tr')
const tdQ = el('td')
tdQ.textContent = `${(m.question || p.marketId).substring(0, 40)}${(m.question || p.marketId).length > 40 ? '…' : ''}`
const tdOutcome = el('td', `td-mono ${p.outcome === 'YES' ? 'td-green' : 'td-red'}`)
tdOutcome.textContent = translateOutcome(p.outcome)
const tdAmt = el('td', 'td-mono')
tdAmt.textContent = `€${p.amountEur.toFixed(0)}`
const tdEntry = el('td', 'td-mono')
tdEntry.textContent = formatPrice(p.entryPrice)
const tdCurrent = el('td', 'td-mono')
tdCurrent.textContent = formatPrice(p.currentPrice)
const tdPnl = el('td', `td-mono ${pnlColor}`)
tdPnl.textContent = `${sign}€${(p.pnl || 0).toFixed(2)}`
const tdKelly = el('td', 'td-mono td-blue')
tdKelly.textContent = `${((p.kellyFraction || 0) * 100).toFixed(0)}%`
const tdDate = el('td', 'td-mono')
tdDate.textContent = formatDate(p.openedAt)
const btn = el('button', 'btn-ghost', 'Cerrar')
btn.addEventListener('click', () => closePositionById(p.id))
const tdBtn = el('td')
tdBtn.appendChild(btn)
tr.append(tdQ, tdOutcome, tdAmt, tdEntry, tdCurrent, tdPnl, tdKelly, tdDate, tdBtn)
tbody.appendChild(tr)
})
}
async function closePositionById(id) {
await simulator.closePosition(id)
await loadPositions()
renderPositions()
renderMiniPositions()
}
/* ─── Render watchlist view ─── */
function renderWatchlist() {
const tbody = document.querySelector('#watchlist-table tbody')
const empty = document.getElementById('watchlist-empty')
if (!tbody) return
if (state.watchlist.length === 0) {
tbody.replaceChildren()
empty.classList.remove('hidden')
return
}
empty.classList.add('hidden')
tbody.replaceChildren()
state.watchlist.forEach((w) => {
const m = state.markets.find((x) => x.id === w.marketId) || w.market || { question: w.marketId, category: '-', yesPrice: 0, noPrice: 0, volumeEur: 0 }
const sig = state.signals.find((s) => s.marketId === w.marketId) || { signal: 'neutral' }
const tr = document.createElement('tr')
const tdQ = el('td')
tdQ.textContent = `${(m.question || w.marketId).substring(0, 40)}${(m.question || w.marketId).length > 40 ? '…' : ''}`
const tdCat = el('td')
tdCat.textContent = m.category || '-'
const tdYes = el('td', 'td-mono td-green')
tdYes.textContent = formatPrice(m.yesPrice)
const tdNo = el('td', 'td-mono td-red')
tdNo.textContent = formatPrice(m.noPrice)
const badge = el('span', `signal-badge ${getSignalBadgeClass(sig.signal)}`)
badge.textContent = getSignalLabel(sig.signal)
const tdSig = el('td')
tdSig.appendChild(badge)
const tdVol = el('td', 'td-mono')
tdVol.textContent = formatCurrency(m.volumeEur || 0)
const tdThreshold = el('td', 'td-mono')
tdThreshold.textContent = w.alertThreshold ? formatPrice(w.alertThreshold) : '-'
const btn = el('button', 'btn-ghost', 'Eliminar')
btn.addEventListener('click', () => removeFromWatchlistById(w.marketId))
const tdBtn = el('td')
tdBtn.appendChild(btn)
tr.append(tdQ, tdCat, tdYes, tdNo, tdSig, tdVol, tdThreshold, tdBtn)
tbody.appendChild(tr)
})
}
async function removeFromWatchlistById(marketId) {
try { await api.removeFromWatchlist(marketId) } catch (e) { console.warn(e) }
state.watchlist = state.watchlist.filter((w) => w.marketId !== marketId)
renderWatchlist()
}
/* ─── Render alerts view ─── */
function renderAlerts() {
const tbody = document.querySelector('#alerts-table tbody')
const empty = document.getElementById('alerts-empty')
if (!tbody) return
if (state.alerts.length === 0) {
tbody.replaceChildren()
empty.classList.remove('hidden')
return
}
empty.classList.add('hidden')
tbody.replaceChildren()
state.alerts.forEach((a) => {
const m = state.markets.find((x) => x.id === a.marketId) || a.market || { question: a.marketId }
const tr = document.createElement('tr')
const tdDate = el('td', 'td-mono')
tdDate.textContent = new Date(a.sentAt).toLocaleString('es-ES')
const tdQ = el('td')
tdQ.textContent = `${(m.question || a.marketId).substring(0, 35)}${(m.question || a.marketId).length > 35 ? '…' : ''}`
const typeBadge = el('span', 'signal-badge sig-neut')
typeBadge.textContent = a.type
const tdType = el('td')
tdType.appendChild(typeBadge)
const tdMsg = el('td')
tdMsg.textContent = a.message
tr.append(tdDate, tdQ, tdType, tdMsg)
tbody.appendChild(tr)
})
}
/* ─── Carga de datos ─── */
async function loadMarkets() {
try {
const batch = await api.getMarkets({ limit: 60, offset: 0 })
state.markets = Array.isArray(batch) ? batch : []
state.signalsOffset = state.markets.length
state.signalsHasMore = state.markets.length === 60
} catch (e) {
console.error('Error cargando mercados:', e)
state.markets = []
state.signalsOffset = 0
state.signalsHasMore = false
}
}
async function loadMoreMarkets() {
if (state.signalsLoading || !state.signalsHasMore) return
state.signalsLoading = true
try {
const batch = await api.getMarkets({ limit: 40, offset: state.signalsOffset })
const arr = Array.isArray(batch) ? batch : []
if (arr.length === 0) {
state.signalsHasMore = false
document.querySelector('.signals-sentinel')?.remove()
if (signalsObserver) { signalsObserver.disconnect(); signalsObserver = null }
} else {
state.markets.push(...arr)
state.signalsOffset += arr.length
state.signalsHasMore = arr.length === 40
try {
const newSigs = await api.getSignalsBatch(arr.map((m) => m.id))
state.signals.push(...newSigs.map((r) => ({ ...r, marketId: r.marketId })))
} catch (e) { /* signals are optional */ }
populateFilters()
applyFilters()
}
} catch (e) {
console.error('Error cargando más mercados:', e)
} finally {
state.signalsLoading = false
}
}
async function loadSignals() {
if (state.markets.length === 0) {
state.signals = []
return
}
try {
const marketIds = state.markets.map((m) => m.id)
const results = await api.getSignalsBatch(marketIds)
state.signals = results.map((r) => ({ ...r, marketId: r.marketId }))
} catch (e) {
console.error('Error cargando señales:', e)
state.signals = []
}
}
async function loadPositions() {
try {
state.positions = await api.getPositions()
} catch (e) {
console.error('Error cargando posiciones:', e)
state.positions = []
}
}
async function loadWatchlist() {
try {
state.watchlist = await api.getWatchlist()
} catch (e) {
console.error('Error cargando watchlist:', e)
state.watchlist = []
}
}
async function loadAlerts() {
try {
state.alerts = await api.getAlerts()
} catch (e) {
console.error('Error cargando alertas:', e)
state.alerts = []
}
}
async function loadStats() {
try {
const stats = await api.getStats()
const statMarkets = document.getElementById('stat-markets')
if (statMarkets) statMarkets.textContent = (stats.marketsCount ?? 0).toLocaleString('es-ES')
const statVolume = document.getElementById('stat-volume')
if (statVolume) statVolume.textContent = formatCurrency(stats.volume24h || 0)
const statSignals = document.getElementById('stat-signals')
if (statSignals) statSignals.textContent = stats.signalsCount ?? 0
const statAlerts = document.getElementById('stat-alerts')
if (statAlerts) statAlerts.textContent = stats.alertsToday ?? 0
} catch (e) {
console.error('Error cargando stats:', e)
}
}
/* ─── Carga de datos de la app ─── */
async function initAppData() {
await loadMarkets()
await loadSignals()
await loadPositions()
await loadWatchlist()
await loadAlerts()
await loadStats()
// Inicializar historial de precios para tracking de trends
state.markets.forEach((m) => {
if (m.yesPrice != null) {
recordPrice(m.id, m.yesPrice)
}
})
populateFilters()
map.init('map-container', state.markets, state.signals, selectMarket)
simulator.init(state)
state.activeMarketId = state.markets[0]?.id || null
renderSignals()
renderDetail()
renderMiniPositions()
}
/* ─── Inicialización ─── */
export async function init() {
document.getElementById('sidebar-toggle')?.addEventListener('click', toggleSidebar)
document.querySelectorAll('.nav-item').forEach((item) => {
item.addEventListener('click', () => switchView(item.dataset.view))
})
document.querySelectorAll('.panel-header[data-panel]').forEach((item) => {
item.addEventListener('click', (e) => {
if (e.target.closest('button, input, a')) return
togglePanel(item.dataset.panel)
})
})
// Telegram modal events
document.getElementById('btn-telegram')?.addEventListener('click', openTelegramModal)
document.getElementById('btn-telegram-mobile')?.addEventListener('click', openTelegramModal)
document.getElementById('telegram-modal-close')?.addEventListener('click', closeTelegramModal)
document.getElementById('telegram-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'telegram-modal') closeTelegramModal()
})
document.getElementById('form-telegram')?.addEventListener('submit', handleTelegramSave)
document.getElementById('btn-test-telegram')?.addEventListener('click', handleTelegramTest)
// Auth modal events
document.getElementById('btn-auth')?.addEventListener('click', openAuthModal)
document.getElementById('modal-close')?.addEventListener('click', closeAuthModal)
document.querySelectorAll('.modal-tab').forEach((tab) => {
tab.addEventListener('click', () => switchAuthTab(tab.dataset.tab))
})
document.getElementById('form-login')?.addEventListener('submit', handleLogin)
document.getElementById('form-register')?.addEventListener('submit', handleRegister)
document.getElementById('auth-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'auth-modal') closeAuthModal()
})
updateAuthButton()
// Si hay token, carga datos; si no, muestra el modal
const authed = await ensureAuth()
if (authed) {
await initAppData()
initFilters()
} else {
openAuthModal()
}
const socket = io()
socket.on('connect', () => console.log('Socket.io conectado'))
socket.on('market_update', (data) => {
const m = state.markets.find((x) => x.id === data.marketId)
if (m) {
// Solo copia campos numericos conocidos — nunca mezcle todo el payload
if (typeof data.yesPrice === 'number') {
recordPrice(m.id, data.yesPrice)
m.yesPrice = data.yesPrice
}
if (typeof data.noPrice === 'number') m.noPrice = data.noPrice
if (typeof data.volumeEur === 'number') m.volumeEur = data.volumeEur
if (typeof data.liquidityEur === 'number') m.liquidityEur = data.liquidityEur
if (state.activeMarketId === data.marketId) renderDetail()
renderSignals()
map.updateBubble(data.marketId, data.yesPrice)
}
})
socket.on('ai_signal', (data) => {
if (!data?.marketId || typeof data.signal !== 'string') return
const idx = state.signals.findIndex((s) => s.marketId === data.marketId)
if (idx >= 0) state.signals[idx] = data
else state.signals.push(data)
renderSignals()
if (state.activeMarketId === data.marketId) renderDetail()
})
socket.on('price_alert', (data) => {
if (!data?.marketId || !data.type) return
state.alerts.unshift(data)
if (state.view === 'alerts') renderAlerts()
})
}