Trading_BOT_GOLD

This commit is contained in:
Flatlogic Bot 2026-04-14 08:09:46 +00:00
parent 81d72b560c
commit d5d42e9a7e
7 changed files with 2296 additions and 149 deletions

View File

@ -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

View File

@ -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,
};

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const strategyPills = [
'Medias móviles',
'Breakout',
'Acción del precio',
'Retrocesos',
'Reversión a la media',
'Trading de noticias',
];
export default function HomePage() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Trading Bot Workspace')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
<div className="space-y-3">
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center '>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<SectionFullScreen bg="violet">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_30%),radial-gradient(circle_at_bottom_right,_rgba(99,102,241,0.16),_transparent_25%),linear-gradient(180deg,#020617_0%,#0f172a_45%,#111827_100%)] px-6 py-10 text-white">
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-3 backdrop-blur">
<div className="flex items-center gap-3">
<div className="rounded-full bg-cyan-400/10 p-2">
<BaseIcon path={mdiRobot} className="text-cyan-300" size={24} />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-cyan-200">Trading Bot Studio</p>
<p className="text-sm text-slate-300">MVP listo para simular sesiones y visualizar el tablero.</p>
</div>
</div>
<BaseButtons type="justify-start" noWrap className="gap-2">
<BaseButton color="whiteDark" label="Login" icon={mdiLogin} href="/login" />
<BaseButton color="info" label="Admin interface" icon={mdiArrowTopRight} href="/dashboard" />
</BaseButtons>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<CardBox className="border border-white/10 bg-white/5 text-white shadow-[0_24px_80px_rgba(15,23,42,0.35)]" cardBoxClassName="p-8 md:p-10">
<div className="inline-flex rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-xs font-medium uppercase tracking-[0.24em] text-cyan-200">
Bot técnico con enfoque moderno
</div>
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight md:text-6xl">
Crea, arranca y supervisa un bot de trading con visualización viva y control total del riesgo.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
La primera entrega conecta configuración, estrategias, credenciales, simulación, tablero operativo, ROI por hora y sesiones recientes sin rehacer el CRUD base.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<BaseButton color="info" label="Entrar al panel" icon={mdiArrowTopRight} href="/dashboard" />
<BaseButton color="whiteDark" label="Ir al login" icon={mdiLogin} href="/login" />
</div>
<BaseDivider />
<div className="flex flex-wrap gap-3">
{strategyPills.map((pill) => (
<span key={pill} className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200">
{pill}
</span>
))}
</div>
</CardBox>
<CardBox className="border border-white/10 bg-[#020617]/90 text-white" cardBoxClassName="p-8">
<p className="text-sm uppercase tracking-[0.18em] text-slate-400">Qué incluye este MVP</p>
<div className="mt-6 space-y-5">
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center gap-3">
<BaseIcon path={mdiShieldCheck} className="text-emerald-300" size={24} />
<span className="text-sm uppercase tracking-[0.18em] text-slate-300">Seguridad operativa</span>
</div>
<p className="mt-4 text-sm leading-7 text-slate-300">
Selector de credenciales API para Binance y Kraken con modo simulación activable desde configuración.
</p>
</div>
<div className="rounded-3xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center gap-3">
<BaseIcon path={mdiChartTimelineVariant} className="text-cyan-300" size={24} />
<span className="text-sm uppercase tracking-[0.18em] text-slate-300">Monitoreo visual</span>
</div>
<p className="mt-4 text-sm leading-7 text-slate-300">
Un tablero elegante para ver todos los movimientos, métricas y eventos relevantes del bot en tiempo real.
</p>
</div>
</div>
</CardBox>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{featureCards.map((card) => (
<CardBox key={card.title} className="border border-white/10 bg-white/5 text-white" cardBoxClassName="p-6">
<div className="rounded-2xl bg-white/5 p-3 w-fit">
<BaseIcon path={card.icon} className="text-cyan-300" size={28} />
</div>
<h2 className="mt-5 text-2xl font-semibold">{card.title}</h2>
<p className="mt-3 text-sm leading-7 text-slate-300">{card.description}</p>
</CardBox>
))}
</div>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

File diff suppressed because it is too large Load Diff