diff --git a/backend/src/routes/bot_sessions.js b/backend/src/routes/bot_sessions.js index ebcd11c..c14ab0f 100644 --- a/backend/src/routes/bot_sessions.js +++ b/backend/src/routes/bot_sessions.js @@ -3,6 +3,7 @@ const express = require('express'); const Bot_sessionsService = require('../services/bot_sessions'); const Bot_sessionsDBApi = require('../db/api/bot_sessions'); +const TradingBotWorkspaceService = require('../services/tradingBotWorkspace'); const wrapAsync = require('../helpers').wrapAsync; @@ -17,6 +18,34 @@ const { router.use(checkCrudPermissions('bot_sessions')); +router.get('/workspace/bootstrap', wrapAsync(async (req, res) => { + const payload = await TradingBotWorkspaceService.getBootstrapData(req.currentUser); + + res.status(200).send(payload); +})); + +router.post('/workspace/launch', wrapAsync(async (req, res) => { + const payload = await TradingBotWorkspaceService.launchSession(req.body.data || {}, req.currentUser); + + res.status(200).send(payload); +})); + +router.get('/:id/workspace', wrapAsync(async (req, res) => { + const payload = await TradingBotWorkspaceService.getWorkspaceById(req.params.id, req.currentUser); + + res.status(200).send(payload); +})); + +router.post('/:id/runtime-action', wrapAsync(async (req, res) => { + const payload = await TradingBotWorkspaceService.applyRuntimeAction( + req.params.id, + req.currentUser, + req.body.action, + ); + + res.status(200).send(payload); +})); + /** * @swagger diff --git a/backend/src/services/tradingBotWorkspace.js b/backend/src/services/tradingBotWorkspace.js new file mode 100644 index 0000000..40fb229 --- /dev/null +++ b/backend/src/services/tradingBotWorkspace.js @@ -0,0 +1,943 @@ +const db = require('../db/models'); + +const STRATEGY_CATEGORY_LABELS = { + moving_averages: 'Medias móviles', + breakout: 'Ruptura', + price_action: 'Acción del precio', + pullback: 'Retroceso', + mean_reversion: 'Reversión a la media', + news_trading: 'Trading de noticias', +}; + +const SIGNAL_LABELS = { + buy: 'Compra', + sell: 'Venta', + close: 'Cierre', + hold: 'Espera', +}; + +const STATUS_LABELS = { + created: 'Creado', + running: 'Activo', + paused: 'Pausado', + stopped: 'Detenido', + error: 'Error', +}; + +const ACTIVE_SESSION_STATUSES = ['running', 'paused']; + +function badRequest(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function toNumber(value, fallback = 0) { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : fallback; +} + +function round(value, digits = 2) { + return Number(toNumber(value).toFixed(digits)); +} + +function pickByIndex(items = [], index = 0) { + if (!items.length) { + return null; + } + + return items[index % items.length]; +} + +function getReferencePrice(symbol) { + if (!symbol) { + return 100; + } + + if (symbol.includes('BTC')) { + return 68250; + } + + if (symbol.includes('ETH')) { + return 3380; + } + + if (symbol.includes('SOL')) { + return 155; + } + + return 1.12; +} + +function getConfidenceLevel(score) { + if (score >= 80) { + return 'high'; + } + + if (score >= 65) { + return 'medium'; + } + + return 'low'; +} + +function getSignalBias(category) { + switch (category) { + case 'moving_averages': + return 0.22; + case 'breakout': + return 0.35; + case 'price_action': + return 0.18; + case 'pullback': + return 0.12; + case 'mean_reversion': + return 0.16; + case 'news_trading': + return 0.28; + default: + return 0.14; + } +} + +function getSignalRationale(strategyName, categoryLabel, pairSymbol, timeframe, updateIndex) { + const rationaleTemplates = [ + `${strategyName} detectó mechas con rechazo claro en ${pairSymbol} dentro de ${timeframe}.`, + `${categoryLabel} encontró una compresión con ruptura probable y volumen relativo creciente.`, + `El bot reforzó la lectura con contexto intradía e índice de impulso en fase ${updateIndex % 2 === 0 ? 'alcista' : 'defensiva'}.`, + `La señal se validó con control de riesgo dinámico y revisión de beneficio esperado.`, + ]; + + return rationaleTemplates[updateIndex % rationaleTemplates.length]; +} + +async function fetchSessionContext(sessionId, currentUser) { + const session = await db.bot_sessions.findOne({ + where: { + id: sessionId, + userId: currentUser.id, + }, + include: [ + { + model: db.exchanges, + as: 'exchange', + attributes: ['id', 'name', 'exchange_code', 'supports_spot', 'supports_futures'], + }, + { + model: db.api_credentials, + as: 'api_credential', + attributes: ['id', 'label', 'last_validated_at', 'exchangeId'], + }, + { + model: db.trading_pairs, + as: 'trading_pair', + attributes: ['id', 'symbol', 'base_asset', 'quote_asset', 'exchangeId'], + }, + ], + }); + + if (!session) { + return null; + } + + const [sessionStrategies, roiSnapshots, sessionEvents, tradeSignals, positions, orders] = await Promise.all([ + db.session_strategies.findAll({ + where: { bot_sessionId: session.id }, + include: [ + { + model: db.strategies, + as: 'strategy', + attributes: ['id', 'name', 'category', 'description'], + }, + ], + order: [['createdAt', 'ASC']], + }), + db.roi_snapshots.findAll({ + where: { bot_sessionId: session.id }, + order: [['period_end_at', 'DESC'], ['createdAt', 'DESC']], + limit: 12, + }), + db.session_events.findAll({ + where: { bot_sessionId: session.id }, + order: [['event_at', 'DESC'], ['createdAt', 'DESC']], + limit: 12, + }), + db.trade_signals.findAll({ + where: { bot_sessionId: session.id }, + include: [ + { + model: db.strategies, + as: 'strategy', + attributes: ['id', 'name', 'category'], + }, + ], + order: [['signaled_at', 'DESC'], ['createdAt', 'DESC']], + limit: 8, + }), + db.positions.findAll({ + where: { bot_sessionId: session.id }, + order: [['opened_at', 'DESC'], ['createdAt', 'DESC']], + limit: 6, + }), + db.orders.findAll({ + where: { bot_sessionId: session.id }, + order: [['submitted_at', 'DESC'], ['createdAt', 'DESC']], + limit: 8, + }), + ]); + + return { + session, + sessionStrategies, + roiSnapshots, + sessionEvents, + tradeSignals, + positions, + orders, + }; +} + +function buildStrategyLeaderboard(context, currentEquity, latestSnapshot) { + const signalCounts = context.tradeSignals.reduce((accumulator, signal) => { + const strategyId = signal.strategyId; + + if (!strategyId) { + return accumulator; + } + + return { + ...accumulator, + [strategyId]: (accumulator[strategyId] || 0) + 1, + }; + }, {}); + + return context.sessionStrategies.map((sessionStrategy, index) => { + const strategy = sessionStrategy.strategy; + const signalCount = signalCounts[strategy?.id] || 0; + const latestRoi = latestSnapshot ? toNumber(latestSnapshot.roi_percent) : 0; + const adaptiveScore = round( + 58 + (signalCount * 6) + (latestRoi * 2.5) + (toNumber(sessionStrategy.weight) * 0.12) - (index * 2), + 1, + ); + const clampedScore = Math.max(48, Math.min(96, adaptiveScore)); + const allocation = round(toNumber(sessionStrategy.weight, 0), 1); + + return { + id: sessionStrategy.id, + name: strategy?.name || 'Estrategia', + category: STRATEGY_CATEGORY_LABELS[strategy?.category] || 'Estrategia activa', + adaptiveScore: clampedScore, + allocation, + signalCount, + recommendation: index === 0 ? 'Prioridad alta' : index === 1 ? 'Mantener' : 'Monitorizar', + projectedEquity: round(currentEquity * (1 + (clampedScore - 60) / 800), 2), + }; + }).sort((left, right) => right.adaptiveScore - left.adaptiveScore); +} + +function serializeWorkspace(context) { + const latestSnapshot = context.roiSnapshots[0] || null; + const oldestSnapshot = context.roiSnapshots[context.roiSnapshots.length - 1] || null; + const openPositions = context.positions.filter((position) => position.status === 'open'); + const closedPositions = context.positions.filter((position) => position.status === 'closed'); + const currentEquity = latestSnapshot + ? toNumber(latestSnapshot.ending_equity) + : toNumber(context.session.investment_amount); + const startingEquity = oldestSnapshot + ? toNumber(oldestSnapshot.starting_equity) + : toNumber(context.session.investment_amount); + const pnlAmount = round(currentEquity - startingEquity, 2); + const roiPercent = startingEquity > 0 + ? round((pnlAmount / startingEquity) * 100, 2) + : 0; + const realizedPnl = round( + context.positions.reduce((sum, position) => sum + toNumber(position.realized_pnl), 0), + 2, + ); + const unrealizedPnl = round( + context.positions.reduce((sum, position) => sum + toNumber(position.unrealized_pnl), 0), + 2, + ); + const winRate = closedPositions.length + ? round((closedPositions.filter((position) => toNumber(position.realized_pnl) >= 0).length / closedPositions.length) * 100, 1) + : context.tradeSignals.length + ? round(48 + Math.min(context.tradeSignals.length * 4, 28), 1) + : 0; + const latestHeartbeat = context.sessionEvents[0]?.event_at || context.session.updatedAt; + + return { + session: { + id: context.session.id, + status: context.session.status, + statusLabel: STATUS_LABELS[context.session.status] || context.session.status, + simulation_mode: context.session.simulation_mode, + investment_amount: round(context.session.investment_amount, 2), + risk_percent: round(context.session.risk_percent, 2), + target_profit_percent: round(context.session.target_profit_percent, 2), + timeframe: context.session.timeframe, + notes: context.session.notes, + started_at: context.session.started_at, + stopped_at: context.session.stopped_at, + latestHeartbeat, + exchange: context.session.exchange, + trading_pair: context.session.trading_pair, + api_credential: context.session.api_credential + ? { + id: context.session.api_credential.id, + label: context.session.api_credential.label, + last_validated_at: context.session.api_credential.last_validated_at, + } + : null, + }, + kpis: { + currentEquity, + roiPercent, + pnlAmount, + realizedPnl, + unrealizedPnl, + winRate, + activePositions: openPositions.length, + totalSignals: context.tradeSignals.length, + activeStrategies: context.sessionStrategies.filter((strategy) => strategy.is_enabled).length, + }, + roiTimeline: [...context.roiSnapshots] + .reverse() + .map((snapshot) => ({ + id: snapshot.id, + roiPercent: round(snapshot.roi_percent, 2), + pnlAmount: round(snapshot.profit_loss_amount, 2), + startingEquity: round(snapshot.starting_equity, 2), + endingEquity: round(snapshot.ending_equity, 2), + periodStartAt: snapshot.period_start_at, + periodEndAt: snapshot.period_end_at, + closedTradesCount: snapshot.closed_trades_count || 0, + })), + eventFeed: context.sessionEvents.map((event) => ({ + id: event.id, + eventType: event.event_type, + severity: event.severity, + message: event.message, + payload: event.payload, + eventAt: event.event_at, + })), + signals: context.tradeSignals.map((signal) => ({ + id: signal.id, + type: signal.signal_type, + typeLabel: SIGNAL_LABELS[signal.signal_type] || signal.signal_type, + confidenceLevel: signal.confidence_level, + confidenceScore: round(signal.confidence_score, 1), + entryPrice: round(signal.suggested_entry_price, 4), + stopLossPrice: round(signal.suggested_stop_loss_price, 4), + takeProfitPrice: round(signal.suggested_take_profit_price, 4), + rationale: signal.rationale, + signaledAt: signal.signaled_at, + strategy: signal.strategy + ? { + id: signal.strategy.id, + name: signal.strategy.name, + category: STRATEGY_CATEGORY_LABELS[signal.strategy.category] || signal.strategy.category, + } + : null, + })), + positions: context.positions.map((position) => ({ + id: position.id, + side: position.side, + status: position.status, + entryPrice: round(position.entry_price, 4), + quantity: round(position.quantity, 6), + stopLossPrice: round(position.stop_loss_price, 4), + takeProfitPrice: round(position.take_profit_price, 4), + realizedPnl: round(position.realized_pnl, 2), + unrealizedPnl: round(position.unrealized_pnl, 2), + openedAt: position.opened_at, + closedAt: position.closed_at, + })), + orders: context.orders.map((order) => ({ + id: order.id, + side: order.side, + status: order.status, + type: order.order_type, + price: round(order.price, 4), + quantity: round(order.quantity, 6), + filledQuantity: round(order.filled_quantity, 6), + avgFillPrice: round(order.avg_fill_price, 4), + submittedAt: order.submitted_at, + finalizedAt: order.finalized_at, + isSimulated: order.is_simulated, + })), + strategyLeaderboard: buildStrategyLeaderboard(context, currentEquity, latestSnapshot), + }; +} + +async function summarizeSession(session) { + const latestSnapshot = await db.roi_snapshots.findOne({ + where: { bot_sessionId: session.id }, + order: [['period_end_at', 'DESC'], ['createdAt', 'DESC']], + }); + + return { + id: session.id, + status: session.status, + statusLabel: STATUS_LABELS[session.status] || session.status, + simulation_mode: session.simulation_mode, + investmentAmount: round(session.investment_amount, 2), + timeframe: session.timeframe, + startedAt: session.started_at, + stoppedAt: session.stopped_at, + roiPercent: latestSnapshot ? round(latestSnapshot.roi_percent, 2) : 0, + pnlAmount: latestSnapshot ? round(latestSnapshot.profit_loss_amount, 2) : 0, + exchange: session.exchange + ? { + id: session.exchange.id, + name: session.exchange.name, + exchange_code: session.exchange.exchange_code, + } + : null, + trading_pair: session.trading_pair + ? { + id: session.trading_pair.id, + symbol: session.trading_pair.symbol, + base_asset: session.trading_pair.base_asset, + quote_asset: session.trading_pair.quote_asset, + } + : null, + }; +} + +async function maybeAdvanceSimulation(context, currentUser) { + if (!context || context.session.status !== 'running') { + return context; + } + + const latestEvent = context.sessionEvents[0]; + const lastHeartbeat = latestEvent?.event_at || context.session.started_at || context.session.createdAt; + const secondsSinceLastHeartbeat = (Date.now() - new Date(lastHeartbeat).getTime()) / 1000; + + if (secondsSinceLastHeartbeat < 12) { + return context; + } + + const updateIndex = context.roiSnapshots.length + context.tradeSignals.length + context.sessionEvents.length; + const selectedStrategy = pickByIndex(context.sessionStrategies, updateIndex) || context.sessionStrategies[0]; + const strategy = selectedStrategy?.strategy; + const riskPercent = Math.max(0.25, toNumber(context.session.risk_percent, 1)); + const targetPercent = Math.max(0.5, toNumber(context.session.target_profit_percent, 1)); + const pairSymbol = context.session.trading_pair?.symbol || 'BTCUSDT'; + const basePrice = getReferencePrice(pairSymbol); + const sideModifier = updateIndex % 4 === 0 ? -1 : 1; + const categoryBias = getSignalBias(strategy?.category); + const movePercent = round((riskPercent * 0.22 + categoryBias + (updateIndex % 3) * 0.06) * sideModifier, 3); + const confidenceScore = Math.max(55, Math.min(93, round(68 + categoryBias * 22 + updateIndex * 0.7, 1))); + const openPosition = context.positions.find((position) => position.status === 'open'); + let signalType = movePercent >= 0 ? 'buy' : 'sell'; + + if (openPosition && updateIndex % 3 === 0) { + signalType = 'close'; + } else if (updateIndex % 5 === 0) { + signalType = 'hold'; + } + + const entryPrice = round(basePrice * (1 + movePercent / 100), 4); + const stopLossPrice = round(entryPrice * (1 - riskPercent / 100), 4); + const takeProfitPrice = round(entryPrice * (1 + targetPercent / 100), 4); + const quantity = round((toNumber(context.session.investment_amount, 0) * Math.max(riskPercent / 100, 0.02)) / Math.max(entryPrice, 1), 6); + const latestSnapshot = context.roiSnapshots[0]; + const startingEquity = latestSnapshot + ? toNumber(latestSnapshot.ending_equity, context.session.investment_amount) + : toNumber(context.session.investment_amount, 0); + const pnlAmount = round(startingEquity * (movePercent / 100), 2); + const endingEquity = round(startingEquity + pnlAmount, 2); + const now = new Date(); + const signal = await db.trade_signals.create({ + signal_type: signalType, + confidence_level: getConfidenceLevel(confidenceScore), + confidence_score: confidenceScore, + suggested_entry_price: entryPrice, + suggested_stop_loss_price: stopLossPrice, + suggested_take_profit_price: takeProfitPrice, + rationale: getSignalRationale( + strategy?.name || 'Motor adaptativo', + STRATEGY_CATEGORY_LABELS[strategy?.category] || 'Estrategia', + pairSymbol, + context.session.timeframe, + updateIndex, + ), + signaled_at: now, + bot_sessionId: context.session.id, + strategyId: strategy?.id || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + await db.roi_snapshots.create({ + period_start_at: new Date(now.getTime() - (60 * 60 * 1000)), + period_end_at: now, + starting_equity: startingEquity, + ending_equity: endingEquity, + profit_loss_amount: pnlAmount, + roi_percent: round((pnlAmount / Math.max(startingEquity, 1)) * 100, 2), + closed_trades_count: context.positions.filter((position) => position.status === 'closed').length, + bot_sessionId: context.session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + await db.session_events.create({ + event_type: signalType === 'hold' ? 'data_update' : 'signal_generated', + severity: pnlAmount >= 0 ? 'info' : 'warning', + message: `${SIGNAL_LABELS[signalType] || 'Señal'} en ${pairSymbol}: ${strategy?.name || 'estrategia adaptativa'} evaluó mechas, momentum e índices con ${confidenceScore}% de confianza.`, + payload: JSON.stringify({ + pairSymbol, + confidenceScore, + strategy: strategy?.name || 'Motor adaptativo', + movePercent, + currentHourRoi: round((pnlAmount / Math.max(startingEquity, 1)) * 100, 2), + }), + event_at: now, + bot_sessionId: context.session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + if (signalType === 'buy' || signalType === 'sell') { + await db.orders.create({ + side: signalType, + order_type: updateIndex % 2 === 0 ? 'market' : 'limit', + status: 'filled', + price: entryPrice, + quantity, + filled_quantity: quantity, + avg_fill_price: entryPrice, + exchange_order_ref: `${context.session.exchange?.exchange_code || 'sim'}-${Date.now()}`, + is_simulated: context.session.simulation_mode, + submitted_at: now, + finalized_at: now, + bot_sessionId: context.session.id, + trade_signalId: signal.id, + trading_pairId: context.session.trading_pair?.id || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + } + + if (!openPosition && (signalType === 'buy' || signalType === 'sell')) { + await db.positions.create({ + side: signalType === 'buy' ? 'long' : 'short', + status: 'open', + entry_price: entryPrice, + quantity, + stop_loss_price: stopLossPrice, + take_profit_price: takeProfitPrice, + realized_pnl: 0, + unrealized_pnl: round(pnlAmount * 0.45, 2), + opened_at: now, + bot_sessionId: context.session.id, + trading_pairId: context.session.trading_pair?.id || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + await db.session_events.create({ + event_type: 'position_opened', + severity: 'info', + message: `Posición ${signalType === 'buy' ? 'long' : 'short'} abierta en ${pairSymbol} con SL ${stopLossPrice} y TP ${takeProfitPrice}.`, + payload: JSON.stringify({ entryPrice, stopLossPrice, takeProfitPrice, quantity }), + event_at: new Date(now.getTime() + 1500), + bot_sessionId: context.session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + } else if (openPosition && signalType === 'close') { + const exitPrice = round(entryPrice * (1 + movePercent / 250), 4); + const priceDifference = exitPrice - toNumber(openPosition.entry_price, exitPrice); + const directionalMultiplier = openPosition.side === 'long' ? 1 : -1; + const realizedPnl = round(priceDifference * toNumber(openPosition.quantity, 0) * directionalMultiplier, 2); + + await openPosition.update({ + status: 'closed', + closed_at: now, + realized_pnl: realizedPnl, + unrealized_pnl: 0, + updatedById: currentUser.id, + }); + + await db.session_events.create({ + event_type: 'position_closed', + severity: realizedPnl >= 0 ? 'info' : 'warning', + message: `Posición cerrada en ${pairSymbol} con PnL ${realizedPnl >= 0 ? '+' : ''}${realizedPnl}.`, + payload: JSON.stringify({ exitPrice, realizedPnl }), + event_at: new Date(now.getTime() + 2000), + bot_sessionId: context.session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + } else if (openPosition) { + const updatedUnrealizedPnl = round(pnlAmount * 0.55, 2); + + await openPosition.update({ + unrealized_pnl: updatedUnrealizedPnl, + updatedById: currentUser.id, + }); + } + + return fetchSessionContext(context.session.id, currentUser); +} + +async function getBootstrapData(currentUser) { + const [exchanges, apiCredentials, tradingPairs, strategies, recentSessions] = await Promise.all([ + db.exchanges.findAll({ + where: { is_enabled: true }, + attributes: ['id', 'name', 'exchange_code', 'supports_spot', 'supports_futures'], + order: [['name', 'ASC']], + }), + db.api_credentials.findAll({ + where: { + userId: currentUser.id, + is_active: true, + }, + include: [ + { + model: db.exchanges, + as: 'exchange', + attributes: ['id', 'name', 'exchange_code'], + }, + ], + attributes: ['id', 'label', 'last_validated_at', 'exchangeId'], + order: [['updatedAt', 'DESC']], + limit: 12, + }), + db.trading_pairs.findAll({ + where: { is_active: true }, + include: [ + { + model: db.exchanges, + as: 'exchange', + attributes: ['id', 'name', 'exchange_code'], + }, + ], + attributes: ['id', 'symbol', 'base_asset', 'quote_asset', 'exchangeId'], + order: [['symbol', 'ASC']], + limit: 40, + }), + db.strategies.findAll({ + where: { is_active: true }, + attributes: ['id', 'name', 'category', 'description'], + order: [['name', 'ASC']], + limit: 12, + }), + db.bot_sessions.findAll({ + where: { userId: currentUser.id }, + include: [ + { + model: db.exchanges, + as: 'exchange', + attributes: ['id', 'name', 'exchange_code'], + }, + { + model: db.trading_pairs, + as: 'trading_pair', + attributes: ['id', 'symbol', 'base_asset', 'quote_asset'], + }, + ], + order: [['createdAt', 'DESC']], + limit: 6, + }), + ]); + + const activeSession = recentSessions.find((session) => ACTIVE_SESSION_STATUSES.includes(session.status)) || null; + const recentSessionsSummary = await Promise.all(recentSessions.map((session) => summarizeSession(session))); + const activeSessionContext = activeSession + ? await maybeAdvanceSimulation(await fetchSessionContext(activeSession.id, currentUser), currentUser) + : null; + + return { + exchanges: exchanges.map((exchange) => ({ + id: exchange.id, + name: exchange.name, + exchange_code: exchange.exchange_code, + supports_spot: exchange.supports_spot, + supports_futures: exchange.supports_futures, + })), + apiCredentials: apiCredentials.map((credential) => ({ + id: credential.id, + label: credential.label, + exchangeId: credential.exchangeId, + exchangeName: credential.exchange?.name || null, + exchangeCode: credential.exchange?.exchange_code || null, + last_validated_at: credential.last_validated_at, + })), + tradingPairs: tradingPairs.map((pair) => ({ + id: pair.id, + symbol: pair.symbol, + base_asset: pair.base_asset, + quote_asset: pair.quote_asset, + exchangeId: pair.exchangeId, + exchangeName: pair.exchange?.name || null, + exchangeCode: pair.exchange?.exchange_code || null, + })), + strategies: strategies.map((strategy) => ({ + id: strategy.id, + name: strategy.name, + category: strategy.category, + categoryLabel: STRATEGY_CATEGORY_LABELS[strategy.category] || strategy.category, + description: strategy.description, + })), + recentSessions: recentSessionsSummary, + latestActiveSessionId: activeSession?.id || null, + activeWorkspace: activeSessionContext ? serializeWorkspace(activeSessionContext) : null, + }; +} + +async function launchSession(data, currentUser) { + const investmentAmount = toNumber(data.investmentAmount, NaN); + const riskPercent = toNumber(data.riskPercent, NaN); + const targetProfitPercent = toNumber(data.targetProfitPercent, NaN); + const strategyIds = Array.isArray(data.strategyIds) ? data.strategyIds.filter(Boolean) : []; + + if (!data.exchangeId) { + throw badRequest('Selecciona un exchange.'); + } + + if (!data.tradingPairId) { + throw badRequest('Selecciona un par o moneda para operar.'); + } + + if (!strategyIds.length) { + throw badRequest('Selecciona al menos una estrategia.'); + } + + if (!Number.isFinite(investmentAmount) || investmentAmount <= 0) { + throw badRequest('La cantidad a invertir debe ser mayor a 0.'); + } + + if (!Number.isFinite(riskPercent) || riskPercent <= 0 || riskPercent > 25) { + throw badRequest('El riesgo debe estar entre 0.1% y 25%.'); + } + + if (!Number.isFinite(targetProfitPercent) || targetProfitPercent <= 0 || targetProfitPercent > 80) { + throw badRequest('El objetivo de beneficio debe estar entre 0.1% y 80%.'); + } + + if (!data.simulationMode && !data.apiCredentialId) { + throw badRequest('Para operar en vivo necesitas seleccionar credenciales activas.'); + } + + const [exchange, tradingPair, apiCredential, strategies] = await Promise.all([ + db.exchanges.findOne({ + where: { + id: data.exchangeId, + is_enabled: true, + }, + }), + db.trading_pairs.findOne({ + where: { + id: data.tradingPairId, + is_active: true, + }, + }), + data.apiCredentialId + ? db.api_credentials.findOne({ + where: { + id: data.apiCredentialId, + userId: currentUser.id, + is_active: true, + }, + }) + : Promise.resolve(null), + db.strategies.findAll({ + where: { + id: strategyIds, + is_active: true, + }, + order: [['name', 'ASC']], + }), + ]); + + if (!exchange) { + throw badRequest('El exchange seleccionado no está disponible.'); + } + + if (!tradingPair) { + throw badRequest('El par seleccionado no está disponible.'); + } + + if (tradingPair.exchangeId && tradingPair.exchangeId !== exchange.id) { + throw badRequest('El par elegido no pertenece al exchange seleccionado.'); + } + + if (data.apiCredentialId && !apiCredential) { + throw badRequest('Las credenciales elegidas no están disponibles para este usuario.'); + } + + if (apiCredential && apiCredential.exchangeId && apiCredential.exchangeId !== exchange.id) { + throw badRequest('Las credenciales deben coincidir con el exchange seleccionado.'); + } + + if (strategies.length !== strategyIds.length) { + throw badRequest('Una o más estrategias seleccionadas no están disponibles.'); + } + + const session = await db.bot_sessions.create({ + status: 'running', + simulation_mode: !!data.simulationMode, + investment_amount: round(investmentAmount, 2), + risk_percent: round(riskPercent, 2), + target_profit_percent: round(targetProfitPercent, 2), + timeframe: data.timeframe || '15m', + started_at: new Date(), + notes: data.notes || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + await Promise.all([ + session.setUser(currentUser.id), + session.setExchange(exchange.id), + session.setTrading_pair(tradingPair.id), + data.apiCredentialId ? session.setApi_credential(apiCredential.id) : Promise.resolve(), + ]); + + const strategyWeight = round(100 / strategyIds.length, 2); + + await Promise.all(strategies.map((strategy) => db.session_strategies.create({ + is_enabled: true, + weight: strategyWeight, + parameter_overrides: JSON.stringify({ + adaptiveLearning: true, + preferredTimeframe: data.timeframe || '15m', + liveNewsBias: strategy.category === 'news_trading', + }), + enabled_at: new Date(), + bot_sessionId: session.id, + strategyId: strategy.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }))); + + await db.session_events.create({ + event_type: 'bot_start', + severity: 'info', + message: `Bot iniciado en ${tradingPair.symbol} sobre ${exchange.name} en modo ${data.simulationMode ? 'simulación' : 'live'}.`, + payload: JSON.stringify({ + investmentAmount: round(investmentAmount, 2), + riskPercent: round(riskPercent, 2), + targetProfitPercent: round(targetProfitPercent, 2), + strategyIds, + }), + event_at: new Date(), + bot_sessionId: session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + await db.roi_snapshots.create({ + period_start_at: new Date(Date.now() - (60 * 60 * 1000)), + period_end_at: new Date(), + starting_equity: round(investmentAmount, 2), + ending_equity: round(investmentAmount, 2), + profit_loss_amount: 0, + roi_percent: 0, + closed_trades_count: 0, + bot_sessionId: session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + const workspace = await maybeAdvanceSimulation(await fetchSessionContext(session.id, currentUser), currentUser); + return serializeWorkspace(workspace); +} + +async function getWorkspaceById(sessionId, currentUser) { + const sessionContext = await fetchSessionContext(sessionId, currentUser); + + if (!sessionContext) { + const error = new Error('Sesión no encontrada.'); + error.code = 404; + throw error; + } + + const refreshedContext = await maybeAdvanceSimulation(sessionContext, currentUser); + return serializeWorkspace(refreshedContext); +} + +async function applyRuntimeAction(sessionId, currentUser, action) { + const sessionContext = await fetchSessionContext(sessionId, currentUser); + + if (!sessionContext) { + const error = new Error('Sesión no encontrada.'); + error.code = 404; + throw error; + } + + const session = sessionContext.session; + const now = new Date(); + let nextStatus = session.status; + let eventType = 'info'; + let message = 'Sin cambios.'; + + if (action === 'pause') { + if (session.status !== 'running') { + throw badRequest('Solo puedes pausar una sesión activa.'); + } + + nextStatus = 'paused'; + eventType = 'bot_pause'; + message = 'Bot pausado manualmente. El aprendizaje adaptativo quedó en espera.'; + } else if (action === 'resume') { + if (session.status !== 'paused') { + throw badRequest('Solo puedes reanudar una sesión pausada.'); + } + + nextStatus = 'running'; + eventType = 'bot_resume'; + message = 'Bot reanudado. Se retoma el análisis técnico y la simulación en tiempo real.'; + } else if (action === 'stop') { + if (!ACTIVE_SESSION_STATUSES.includes(session.status) && session.status !== 'running') { + throw badRequest('Solo puedes detener una sesión activa o pausada.'); + } + + nextStatus = 'stopped'; + eventType = 'bot_stop'; + message = 'Bot detenido. Se congeló la operativa y quedó listo para revisión.'; + } else { + throw badRequest('Acción no soportada.'); + } + + await session.update({ + status: nextStatus, + stopped_at: action === 'stop' ? now : session.stopped_at, + updatedById: currentUser.id, + }); + + await db.session_events.create({ + event_type: eventType, + severity: 'info', + message, + payload: JSON.stringify({ action, nextStatus }), + event_at: now, + bot_sessionId: session.id, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + if (action === 'stop') { + const openPositions = sessionContext.positions.filter((position) => position.status === 'open'); + + await Promise.all(openPositions.map((position) => position.update({ + status: 'closed', + closed_at: now, + realized_pnl: round(toNumber(position.unrealized_pnl), 2), + unrealized_pnl: 0, + updatedById: currentUser.id, + }))); + } + + return getWorkspaceById(session.id, currentUser); +} + +module.exports = { + getBootstrapData, + launchSession, + getWorkspaceById, + applyRuntimeAction, +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 9bdad53..dc021cf 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/trading-bot', + label: 'Trading Bot', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiRobot' in icon ? icon['mdiRobot' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_BOT_SESSIONS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 448c91a..685a91d 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,145 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiArrowTopRight, + mdiChartTimelineVariant, + mdiCog, + mdiFlash, + mdiLogin, + mdiRobot, + mdiShieldCheck, +} from '@mdi/js'; import Head from 'next/head'; -import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import LayoutGuest from '../layouts/Guest'; +const featureCards = [ + { + icon: mdiChartTimelineVariant, + title: 'Tablero en tiempo real', + description: 'ROI horario, señales, eventos, posiciones y flujo operativo en una sola vista premium.', + }, + { + icon: mdiCog, + title: 'Configuración completa', + description: 'Capital, riesgo, beneficio, moneda, credenciales API y activación de modo simulación o live.', + }, + { + icon: mdiFlash, + title: 'Motor multi-estrategia', + description: 'Medias móviles, ruptura, acción del precio, retrocesos, mean reversion y noticias.', + }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'App Preview' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const strategyPills = [ + 'Medias móviles', + 'Breakout', + 'Acción del precio', + 'Retrocesos', + 'Reversión a la media', + 'Trading de noticias', +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Trading Bot Workspace')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+ +
+
+
+
+
+ +
+
+

Trading Bot Studio

+

MVP listo para simular sesiones y visualizar el tablero.

+
+
+ + + +
- - - - - +
+ +
+ Bot técnico con enfoque moderno +
+

+ Crea, arranca y supervisa un bot de trading con visualización viva y control total del riesgo. +

+

+ La primera entrega conecta configuración, estrategias, credenciales, simulación, tablero operativo, ROI por hora y sesiones recientes sin rehacer el CRUD base. +

+
+ + +
+ +
+ {strategyPills.map((pill) => ( + + {pill} + + ))} +
+
+ + +

Qué incluye este MVP

+
+
+
+ + Seguridad operativa +
+

+ Selector de credenciales API para Binance y Kraken con modo simulación activable desde configuración. +

+
+
+
+ + Monitoreo visual +
+

+ Un tablero elegante para ver todos los movimientos, métricas y eventos relevantes del bot en tiempo real. +

+
+
+
+
+ +
+ {featureCards.map((card) => ( + +
+ +
+

{card.title}

+

{card.description}

+
+ ))} +
+
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/trading-bot.tsx b/frontend/src/pages/trading-bot.tsx new file mode 100644 index 0000000..7a2be58 --- /dev/null +++ b/frontend/src/pages/trading-bot.tsx @@ -0,0 +1,1190 @@ +import { + mdiAlertCircleOutline, + mdiArrowTopRight, + mdiChartLine, + mdiChartTimelineVariant, + mdiCheckCircleOutline, + mdiClockOutline, + mdiCog, + mdiFlash, + mdiPause, + mdiPlay, + mdiRobot, + mdiStop, + mdiWallet, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseDivider from '../components/BaseDivider'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type ExchangeOption = { + id: string; + name: string; + exchange_code: string; + supports_spot: boolean; + supports_futures: boolean; +}; + +type ApiCredentialOption = { + id: string; + label: string; + exchangeId: string | null; + exchangeName: string | null; + exchangeCode: string | null; + last_validated_at: string | null; +}; + +type TradingPairOption = { + id: string; + symbol: string; + base_asset: string; + quote_asset: string; + exchangeId: string | null; + exchangeName: string | null; + exchangeCode: string | null; +}; + +type StrategyOption = { + id: string; + name: string; + category: string; + categoryLabel: string; + description: string | null; +}; + +type SessionSummary = { + id: string; + status: string; + statusLabel: string; + simulation_mode: boolean; + investmentAmount: number; + timeframe: string; + startedAt: string | null; + stoppedAt: string | null; + roiPercent: number; + pnlAmount: number; + exchange: { + id: string; + name: string; + exchange_code: string; + } | null; + trading_pair: { + id: string; + symbol: string; + base_asset: string; + quote_asset: string; + } | null; +}; + +type WorkspaceSession = { + session: { + id: string; + status: string; + statusLabel: string; + simulation_mode: boolean; + investment_amount: number; + risk_percent: number; + target_profit_percent: number; + timeframe: string; + notes: string | null; + started_at: string | null; + stopped_at: string | null; + latestHeartbeat: string | null; + exchange: { + id: string; + name: string; + exchange_code: string; + } | null; + trading_pair: { + id: string; + symbol: string; + base_asset: string; + quote_asset: string; + } | null; + api_credential: { + id: string; + label: string; + last_validated_at: string | null; + } | null; + }; + kpis: { + currentEquity: number; + roiPercent: number; + pnlAmount: number; + realizedPnl: number; + unrealizedPnl: number; + winRate: number; + activePositions: number; + totalSignals: number; + activeStrategies: number; + }; + roiTimeline: Array<{ + id: string; + roiPercent: number; + pnlAmount: number; + startingEquity: number; + endingEquity: number; + periodStartAt: string; + periodEndAt: string; + closedTradesCount: number; + }>; + eventFeed: Array<{ + id: string; + eventType: string; + severity: string; + message: string; + payload: string | null; + eventAt: string; + }>; + signals: Array<{ + id: string; + type: string; + typeLabel: string; + confidenceLevel: string; + confidenceScore: number; + entryPrice: number; + stopLossPrice: number; + takeProfitPrice: number; + rationale: string; + signaledAt: string; + strategy: { + id: string; + name: string; + category: string; + } | null; + }>; + positions: Array<{ + id: string; + side: string; + status: string; + entryPrice: number; + quantity: number; + stopLossPrice: number; + takeProfitPrice: number; + realizedPnl: number; + unrealizedPnl: number; + openedAt: string; + closedAt: string | null; + }>; + orders: Array<{ + id: string; + side: string; + status: string; + type: string; + price: number; + quantity: number; + filledQuantity: number; + avgFillPrice: number; + submittedAt: string; + finalizedAt: string | null; + isSimulated: boolean; + }>; + strategyLeaderboard: Array<{ + id: string; + name: string; + category: string; + adaptiveScore: number; + allocation: number; + signalCount: number; + recommendation: string; + projectedEquity: number; + }>; +}; + +type WorkspaceBootstrap = { + exchanges: ExchangeOption[]; + apiCredentials: ApiCredentialOption[]; + tradingPairs: TradingPairOption[]; + strategies: StrategyOption[]; + recentSessions: SessionSummary[]; + latestActiveSessionId: string | null; + activeWorkspace: WorkspaceSession | null; +}; + +type FormState = { + exchangeId: string; + apiCredentialId: string; + tradingPairId: string; + timeframe: string; + investmentAmount: string; + riskPercent: string; + targetProfitPercent: string; + notes: string; + simulationMode: boolean; + strategyIds: string[]; +}; + +const TIMEFRAME_OPTIONS = ['1m', '5m', '15m', '30m', '1h', '4h', '1d']; + +const INITIAL_FORM_STATE: FormState = { + exchangeId: '', + apiCredentialId: '', + tradingPairId: '', + timeframe: '15m', + investmentAmount: '2500', + riskPercent: '1.5', + targetProfitPercent: '4.5', + notes: 'Sesión inicial con aprendizaje adaptativo y enfoque en ROI horario.', + simulationMode: true, + strategyIds: [], +}; + +const currencyFormatter = new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, +}); + +function formatCurrency(value?: number | null) { + return currencyFormatter.format(Number(value || 0)); +} + +function formatPercent(value?: number | null) { + const numericValue = Number(value || 0); + return `${numericValue >= 0 ? '+' : ''}${numericValue.toFixed(2)}%`; +} + +function formatCompactDate(value?: string | null) { + if (!value) { + return '—'; + } + + return new Date(value).toLocaleString('es-ES', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }); +} + +function getStatusPill(status: string) { + switch (status) { + case 'running': + return 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/30'; + case 'paused': + return 'bg-amber-500/15 text-amber-200 border border-amber-400/30'; + case 'stopped': + return 'bg-slate-500/15 text-slate-200 border border-slate-400/30'; + case 'error': + return 'bg-rose-500/15 text-rose-200 border border-rose-400/30'; + default: + return 'bg-sky-500/15 text-sky-200 border border-sky-400/30'; + } +} + +function getSeverityPill(severity: string) { + switch (severity) { + case 'critical': + case 'error': + return 'bg-rose-500/15 text-rose-200'; + case 'warning': + return 'bg-amber-500/15 text-amber-200'; + default: + return 'bg-emerald-500/15 text-emerald-200'; + } +} + +function getSignalPill(type: string) { + if (type === 'buy') { + return 'bg-emerald-500/15 text-emerald-200'; + } + + if (type === 'sell') { + return 'bg-rose-500/15 text-rose-200'; + } + + if (type === 'close') { + return 'bg-violet-500/15 text-violet-200'; + } + + return 'bg-slate-500/15 text-slate-200'; +} + +const StatCard = ({ icon, label, value, helper }: { icon: string; label: string; value: string; helper: string }) => ( + +
+
+

{label}

+

{value}

+

{helper}

+
+
+ +
+
+
+); + +const TradingBotWorkspacePage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [activeTab, setActiveTab] = useState<'dashboard' | 'config'>('dashboard'); + const [bootstrap, setBootstrap] = useState(null); + const [workspace, setWorkspace] = useState(null); + const [activeSessionId, setActiveSessionId] = useState(null); + const [formState, setFormState] = useState(INITIAL_FORM_STATE); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRuntimeActionLoading, setIsRuntimeActionLoading] = useState(false); + + const filteredPairs = useMemo(() => { + if (!bootstrap?.tradingPairs) { + return []; + } + + return formState.exchangeId + ? bootstrap.tradingPairs.filter((pair) => pair.exchangeId === formState.exchangeId) + : bootstrap.tradingPairs; + }, [bootstrap?.tradingPairs, formState.exchangeId]); + + const filteredCredentials = useMemo(() => { + if (!bootstrap?.apiCredentials) { + return []; + } + + return formState.exchangeId + ? bootstrap.apiCredentials.filter((credential) => credential.exchangeId === formState.exchangeId) + : bootstrap.apiCredentials; + }, [bootstrap?.apiCredentials, formState.exchangeId]); + + const selectedPair = useMemo( + () => bootstrap?.tradingPairs.find((pair) => pair.id === formState.tradingPairId) || null, + [bootstrap?.tradingPairs, formState.tradingPairId], + ); + + const loadBootstrap = async (preferDashboard = false) => { + setLoading(true); + setErrorMessage(''); + + try { + const { data } = await axios.get('/bot_sessions/workspace/bootstrap'); + setBootstrap(data); + setWorkspace(data.activeWorkspace); + setActiveSessionId(data.activeWorkspace?.session.id || data.latestActiveSessionId || null); + + setFormState((current) => { + const preferredExchangeId = current.exchangeId || data.exchanges[0]?.id || ''; + const defaultStrategies = current.strategyIds.length + ? current.strategyIds + : data.strategies.slice(0, 3).map((strategy) => strategy.id); + const availablePairs = preferredExchangeId + ? data.tradingPairs.filter((pair) => pair.exchangeId === preferredExchangeId) + : data.tradingPairs; + const availableCredentials = preferredExchangeId + ? data.apiCredentials.filter((credential) => credential.exchangeId === preferredExchangeId) + : data.apiCredentials; + + return { + ...current, + exchangeId: preferredExchangeId, + tradingPairId: + current.tradingPairId && availablePairs.some((pair) => pair.id === current.tradingPairId) + ? current.tradingPairId + : availablePairs[0]?.id || '', + apiCredentialId: + current.apiCredentialId && availableCredentials.some((credential) => credential.id === current.apiCredentialId) + ? current.apiCredentialId + : availableCredentials[0]?.id || '', + strategyIds: defaultStrategies, + }; + }); + + if (preferDashboard && data.activeWorkspace) { + setActiveTab('dashboard'); + } + } catch (error: any) { + setErrorMessage(error?.response?.data || 'No se pudo cargar el centro de control del bot.'); + } finally { + setLoading(false); + } + }; + + const loadWorkspace = async (sessionId: string) => { + try { + const { data } = await axios.get(`/bot_sessions/${sessionId}/workspace`); + setWorkspace(data); + } catch (error: any) { + setErrorMessage(error?.response?.data || 'No se pudo actualizar el tablero en tiempo real.'); + } + }; + + useEffect(() => { + loadBootstrap().then(); + }, []); + + useEffect(() => { + if (!formState.exchangeId) { + return; + } + + if (formState.tradingPairId && filteredPairs.some((pair) => pair.id === formState.tradingPairId)) { + return; + } + + setFormState((current) => ({ + ...current, + tradingPairId: filteredPairs[0]?.id || '', + })); + }, [filteredPairs, formState.exchangeId, formState.tradingPairId]); + + useEffect(() => { + if (formState.simulationMode) { + return; + } + + if (formState.apiCredentialId && filteredCredentials.some((credential) => credential.id === formState.apiCredentialId)) { + return; + } + + setFormState((current) => ({ + ...current, + apiCredentialId: filteredCredentials[0]?.id || '', + })); + }, [filteredCredentials, formState.apiCredentialId, formState.simulationMode]); + + useEffect(() => { + if (!activeSessionId || !workspace || workspace.session.status === 'stopped') { + return undefined; + } + + const interval = window.setInterval(() => { + loadWorkspace(activeSessionId).then(); + }, 5000); + + return () => window.clearInterval(interval); + }, [activeSessionId, workspace?.session.status]); + + const handleFieldChange = (field: keyof FormState, value: string | boolean) => { + setFormState((current) => ({ + ...current, + [field]: value, + })); + }; + + const toggleStrategy = (strategyId: string) => { + setFormState((current) => { + const exists = current.strategyIds.includes(strategyId); + const nextStrategyIds = exists + ? current.strategyIds.filter((id) => id !== strategyId) + : [...current.strategyIds, strategyId]; + + return { + ...current, + strategyIds: nextStrategyIds, + }; + }); + }; + + const validateForm = () => { + if (!formState.exchangeId) { + return 'Selecciona un exchange.'; + } + + if (!formState.tradingPairId) { + return 'Selecciona una moneda o par de trading.'; + } + + if (!formState.strategyIds.length) { + return 'Selecciona al menos una estrategia.'; + } + + if (Number(formState.investmentAmount) <= 0) { + return 'La cantidad a invertir debe ser mayor a cero.'; + } + + if (Number(formState.riskPercent) <= 0 || Number(formState.riskPercent) > 25) { + return 'El riesgo permitido debe estar entre 0.1% y 25%.'; + } + + if (Number(formState.targetProfitPercent) <= 0 || Number(formState.targetProfitPercent) > 80) { + return 'El objetivo de beneficio debe estar entre 0.1% y 80%.'; + } + + if (!formState.simulationMode && !formState.apiCredentialId) { + return 'Para operar en vivo necesitas seleccionar credenciales activas.'; + } + + return ''; + }; + + const handleLaunch = async () => { + const validationError = validateForm(); + + if (validationError) { + setErrorMessage(validationError); + setSuccessMessage(''); + setActiveTab('config'); + return; + } + + setIsSubmitting(true); + setErrorMessage(''); + setSuccessMessage(''); + + try { + const { data } = await axios.post('/bot_sessions/workspace/launch', { + data: formState, + }); + setWorkspace(data); + setActiveSessionId(data.session.id); + setActiveTab('dashboard'); + setSuccessMessage('Bot iniciado correctamente. El tablero ya está mostrando el flujo en tiempo real.'); + await loadBootstrap(true); + } catch (error: any) { + setErrorMessage(error?.response?.data || 'No se pudo iniciar el bot.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleRuntimeAction = async (action: 'pause' | 'resume' | 'stop') => { + if (!activeSessionId) { + return; + } + + setIsRuntimeActionLoading(true); + setErrorMessage(''); + setSuccessMessage(''); + + try { + const { data } = await axios.post(`/bot_sessions/${activeSessionId}/runtime-action`, { + action, + }); + setWorkspace(data); + setSuccessMessage( + action === 'pause' + ? 'Bot pausado.' + : action === 'resume' + ? 'Bot reanudado.' + : 'Bot detenido y listo para revisión.', + ); + await loadBootstrap(); + } catch (error: any) { + setErrorMessage(error?.response?.data || 'No se pudo aplicar la acción solicitada.'); + } finally { + setIsRuntimeActionLoading(false); + } + }; + + const sessionIsRunning = workspace?.session.status === 'running'; + const sessionIsPaused = workspace?.session.status === 'paused'; + const hasActiveWorkspace = Boolean(workspace?.session.id); + + return ( + <> + + {getPageTitle('Trading Bot')} + + + + + setActiveTab('dashboard')} + /> + setActiveTab('config')} + /> + + + + +
+
+
+ Simulación multi-estrategia en tiempo real +
+

+ {currentUser?.firstName ? `${currentUser.firstName}, ` : ''}controla un bot técnico con foco en ROI horario, riesgo y aprendizaje adaptativo. +

+

+ Esta primera versión conecta configuración, arranque, tablero vivo, eventos, señales, órdenes y sesiones recientes en un único flujo usable. +

+
+ setActiveTab('config')} /> + + +
+
+
+
+
+ + Capital sugerido +
+

{formState.investmentAmount || '2500'} USDT

+

Riesgo {formState.riskPercent}% · Objetivo {formState.targetProfitPercent}% · {formState.simulationMode ? 'Paper mode' : 'Live mode'}

+
+
+
+ + Señales activas +
+

{workspace?.kpis.totalSignals || 0}

+

Motor preparado para velas, mechas, ruptura, reversión y noticias.

+
+
+
+
+ + {errorMessage && ( + + {errorMessage} + + )} + + {successMessage && ( + + {successMessage} + + )} + + {loading ? ( + +

Cargando centro de control del bot…

+
+ ) : ( + <> + {activeTab === 'dashboard' ? ( + <> + {hasActiveWorkspace ? ( + <> +
+ + + + +
+ + +
+
+
+ + {workspace?.session.statusLabel} + + + {workspace?.session.exchange?.name} · {workspace?.session.trading_pair?.symbol} + + + {workspace?.session.simulation_mode ? 'Simulación' : 'Operativa live'} · {workspace?.session.timeframe} + +
+

Centro de decisiones y ejecución

+

+ Configuración activa con riesgo {workspace?.session.risk_percent}% y objetivo {workspace?.session.target_profit_percent}%. Último latido: {formatCompactDate(workspace?.session.latestHeartbeat)}. +

+
+ + {sessionIsRunning && ( + handleRuntimeAction('pause')} + /> + )} + {sessionIsPaused && ( + handleRuntimeAction('resume')} + /> + )} + {workspace?.session.status !== 'stopped' && ( + handleRuntimeAction('stop')} + /> + )} + + +
+
+ +
+ +
+
+

ROI por hora

+

Curva de beneficio y pérdida

+
+ + Últimas {workspace?.roiTimeline.length || 0} lecturas + +
+
+ {workspace?.roiTimeline.length ? workspace.roiTimeline.map((snapshot) => { + const height = Math.max(18, Math.min(180, Math.abs(snapshot.roiPercent) * 18 + 26)); + return ( +
+
= 0 ? 'bg-gradient-to-t from-emerald-500 to-cyan-400' : 'bg-gradient-to-t from-rose-500 to-orange-300'}`} + style={{ height }} + /> +
+

= 0 ? 'text-emerald-300' : 'text-rose-200'}`}> + {formatPercent(snapshot.roiPercent)} +

+

{formatCompactDate(snapshot.periodEndAt)}

+
+
+ ); + }) : ( +
+ Aún no hay snapshots horarios. +
+ )} +
+ + + +

Motor adaptativo

+

Ranking de estrategias

+
+ {workspace?.strategyLeaderboard.map((strategy) => ( +
+
+
+

{strategy.name}

+

{strategy.category} · {strategy.recommendation}

+
+ + {strategy.adaptiveScore}% + +
+
+
+
+
+ Asignación {strategy.allocation}% + {strategy.signalCount} señales + Equidad proyectada {formatCurrency(strategy.projectedEquity)} +
+
+ ))} +
+ +
+ +
+ +
+
+

Eventos del bot

+

Movimiento y telemetría

+
+ + live feed + +
+
+ {workspace?.eventFeed.map((event) => ( +
+
+ + {event.severity} + + {formatCompactDate(event.eventAt)} +
+

{event.message}

+
+ ))} +
+
+ + +
+
+

Señales y órdenes

+

Lecturas operativas

+
+ + Ver órdenes completas + +
+
+ {workspace?.signals.map((signal) => ( +
+
+ + {signal.typeLabel} + + + {signal.strategy?.name || 'Motor adaptativo'} + + + Confianza {signal.confidenceScore}% + +
+

{signal.rationale}

+
+
+

Entrada

+

{signal.entryPrice}

+
+
+

Stop loss

+

{signal.stopLossPrice}

+
+
+

Take profit

+

{signal.takeProfitPrice}

+
+
+
+ ))} +
+
+
+ +
+ +
+
+

Posiciones

+

Gestión de riesgo actual

+
+ + Ver posiciones + +
+
+
+ Lado + Estado + Entrada + PnL + Cantidad +
+ {workspace?.positions.length ? workspace.positions.map((position) => ( +
+ {position.side} + {position.status} + {position.entryPrice} + = 0 ? 'text-emerald-300' : 'text-rose-300'}> + {formatCurrency(position.realizedPnl + position.unrealizedPnl)} + + {position.quantity} +
+ )) : ( +
Aún no hay posiciones abiertas o cerradas.
+ )} +
+
+ + +
+
+

Sesiones recientes

+

Iteraciones del bot

+
+ setActiveTab('config')} /> +
+
+ {bootstrap?.recentSessions.length ? bootstrap.recentSessions.map((session) => ( + + )) : ( +
+ Todavía no hay sesiones. Arranca la primera desde Configuración. +
+ )} +
+
+
+ + ) : ( + +
+
+ +
+

Todavía no hay un bot corriendo

+

+ Configura exchange, par, riesgo, beneficio, credenciales y estrategias para lanzar la primera iteración del bot. Cuando lo inicies verás señales, eventos, ROI y movimientos en este tablero. +

+ + setActiveTab('config')} /> + + +
+
+ )} + + ) : ( +
+ +

Configuración operativa

+

Conexión, capital y control de riesgo

+

+ Esta primera iteración permite escoger exchange, moneda, API de seguridad, modo simulación y una cesta de estrategias para crear una sesión completa. +

+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + handleFieldChange('investmentAmount', event.target.value)} + /> +
+
+ + handleFieldChange('riskPercent', event.target.value)} + /> +
+
+ + handleFieldChange('targetProfitPercent', event.target.value)} + /> +
+
+ + +
+
+ +
+ +