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) => ( -
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -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.
++ La primera entrega conecta configuración, estrategias, credenciales, simulación, tablero operativo, ROI por hora y sesiones recientes sin rehacer el CRUD base. +
+Qué incluye este MVP
++ Selector de credenciales API para Binance y Kraken con modo simulación activable desde configuración. +
++ Un tablero elegante para ver todos los movimientos, métricas y eventos relevantes del bot en tiempo real. +
+{card.description}
+© 2026 {title}. All rights reserved
- - Privacy Policy - -{label}
+{value}
+{helper}
++ Esta primera versión conecta configuración, arranque, tablero vivo, eventos, señales, órdenes y sesiones recientes en un único flujo usable. +
+{formState.investmentAmount || '2500'} USDT
+Riesgo {formState.riskPercent}% · Objetivo {formState.targetProfitPercent}% · {formState.simulationMode ? 'Paper mode' : 'Live mode'}
+{workspace?.kpis.totalSignals || 0}
+Motor preparado para velas, mechas, ruptura, reversión y noticias.
+Cargando centro de control del bot…
++ Configuración activa con riesgo {workspace?.session.risk_percent}% y objetivo {workspace?.session.target_profit_percent}%. Último latido: {formatCompactDate(workspace?.session.latestHeartbeat)}. +
+ROI por hora
+= 0 ? 'text-emerald-300' : 'text-rose-200'}`}> + {formatPercent(snapshot.roiPercent)} +
+{formatCompactDate(snapshot.periodEndAt)}
+Motor adaptativo
+{strategy.name}
+{strategy.category} · {strategy.recommendation}
+Eventos del bot
+{event.message}
+Señales y órdenes
+{signal.rationale}
+Entrada
+{signal.entryPrice}
+Stop loss
+{signal.stopLossPrice}
+Take profit
+{signal.takeProfitPrice}
+Posiciones
+Sesiones recientes
++ 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. +
+Configuración operativa
++ 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. +
+ +Estrategias habilitadas
++ El motor puede combinar varias lecturas técnicas y redistribuir prioridad según el rendimiento observado en las señales y el ROI horario. +
+Resumen listo para ejecutar
+