Jose Salazar
Correccion de bugs del pipeline de IA, incorporo openrouter con deepseek para fallback, rate limit de login diferenciado entre prod y dev, cambios varios en UI
8a4b117 | /** | |
| * Logica de negocio del modulo de posiciones (simulador virtual). | |
| * | |
| * Responsabilidades: | |
| * - open(userId, { marketId, outcome, amountEur }) | |
| * → valida que el mercado exista y este activo, obtiene precio de entrada, | |
| * calcula fraccion de Kelly y crea la posicion. | |
| * - list(userId, status) → devuelve posiciones del usuario. | |
| * - close(id, userId) → calcula P&L final con el precio actual y marca como cerrada. | |
| * - updateAllPnL() → recalcula P&L de todas las posiciones abiertas (scheduler). | |
| * | |
| * Calculos: | |
| * - P&L = amountEur * (currentPrice / entryPrice - 1). | |
| * - Kelly = (odds * pWin - pLose) / odds, capped al 25%. | |
| * | |
| * Consumido por: | |
| * - positions.controller.js (API REST). | |
| * - scheduler.js (updatePositionsPnL cada 30s). | |
| */ | |
| import { HttpError } from '../utils/apiResponse.js'; | |
| import { positionsRepository } from './positions.repository.js'; | |
| import { marketsRepository } from '../markets/markets.repository.js'; | |
| import { signalsRepository } from '../signals/signals.repository.js'; | |
| import { kellyFraction, suggestSize } from './kelly.js'; | |
| function currentPriceForOutcome(market, outcome) { | |
| return outcome === 'YES' ? market.yesPrice : market.noPrice; | |
| } | |
| function calcPnl(amountEur, entryPrice, currentPrice) { | |
| if (!currentPrice || !entryPrice) return 0; | |
| return amountEur * (currentPrice / entryPrice - 1); | |
| } | |
| export const positionsService = { | |
| async open(userId, { marketId, outcome, amountEur }) { | |
| const market = await marketsRepository.findById(marketId); | |
| if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found'); | |
| if (market.status !== 'active') throw new HttpError(409, 'MARKET_CLOSED', 'Market is not active'); | |
| const entryPrice = currentPriceForOutcome(market, outcome); | |
| if (!entryPrice) throw new HttpError(409, 'NO_PRICE', 'Market price unavailable'); | |
| const latestSignal = await signalsRepository.findLatestByMarket(marketId); | |
| const confidence = latestSignal?.confidence ?? 0.5; | |
| const fraction = kellyFraction(entryPrice, confidence); | |
| return positionsRepository.create({ | |
| userId, | |
| marketId, | |
| outcome, | |
| amountEur, | |
| entryPrice, | |
| currentPrice: entryPrice, | |
| pnl: 0, | |
| kellyFraction: fraction, | |
| status: 'open', | |
| }); | |
| }, | |
| list(userId, status) { | |
| return positionsRepository.findByUser(userId, status); | |
| }, | |
| async close(id, userId) { | |
| const position = await positionsRepository.findByIdAndUser(id, userId); | |
| if (!position) throw new HttpError(404, 'NOT_FOUND', 'Position not found'); | |
| if (position.status === 'closed') throw new HttpError(409, 'ALREADY_CLOSED', 'Position already closed'); | |
| const market = await marketsRepository.findById(position.marketId); | |
| const currentPrice = market ? currentPriceForOutcome(market, position.outcome) : position.entryPrice; | |
| const finalPnl = calcPnl(position.amountEur, position.entryPrice, currentPrice); | |
| return positionsRepository.update(id, { | |
| status: 'closed', | |
| currentPrice, | |
| pnl: finalPnl, | |
| closedAt: new Date(), | |
| }); | |
| }, | |
| /** | |
| * Sugiere outcome y tamano de posicion para un mercado, basado en: | |
| * - Spread bid/ask (resta del edge) | |
| * - Edge de la senal IA mas reciente (impliedProb vs fairProb) | |
| * - Quarter-Kelly capado al 25% del bankroll | |
| * | |
| * Devuelve { outcome, fraction, amountEur, edgeNet, illiquid, note }. | |
| */ | |
| async suggest(marketId, bankroll = 1000) { | |
| const market = await marketsRepository.findById(marketId); | |
| if (!market) throw new HttpError(404, 'NOT_FOUND', 'Market not found'); | |
| const signal = await signalsRepository.findLatestByMarket(marketId); | |
| return suggestSize({ | |
| yesPrice: market.yesPrice, | |
| noPrice: market.noPrice, | |
| spread: market.spread ?? 0, | |
| signal, | |
| bankroll, | |
| }); | |
| }, | |
| async updateAllPnL() { | |
| const open = await positionsRepository.findAllOpen(); | |
| await Promise.all( | |
| open.map((pos) => { | |
| const currentPrice = currentPriceForOutcome(pos.market, pos.outcome); | |
| if (!currentPrice) return Promise.resolve(); | |
| return positionsRepository.update(pos.id, { | |
| currentPrice, | |
| pnl: calcPnl(pos.amountEur, pos.entryPrice, currentPrice), | |
| }); | |
| }), | |
| ); | |
| }, | |
| }; | |