โ† ะะฐะทะฐะด
'use strict'; const prisma = require('./db'); const logger = require('../utils/logger'); const { getContractUnit } = require('./cache'); // โ”€โ”€โ”€ Parse Binance option symbol โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // "BTC-260424-95000-C" โ†’ { underlying: "BTC", expiry: "260424", strike: 95000, optionType: "CALL" } function parseSymbol(symbol) { const parts = symbol.split('-'); if (parts.length !== 4) return null; return { underlying: parts[0], expiry: parts[1], strike: parseFloat(parts[2]), optionType: parts[3] === 'C' ? 'CALL' : 'PUT', }; } // โ”€โ”€โ”€ Log a completed trade โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Called from tradeManager when a trade closes (TP hit, manual close, expired) async function logTrade({ tradeId, symbol, side, // BUY (long option) or SELL (short/wrote) quantity, entryPrice, exitPrice, entryTime, exitTime, source = 'manual', exitReason = null, }) { try { const parsed = parseSymbol(symbol); if (!parsed) { logger.warn(`[TRADE_LOG] Cannot parse symbol: ${symbol}`); return null; } const pnl = side === 'BUY' ? (exitPrice - entryPrice) * quantity : (entryPrice - exitPrice) * quantity; const roi = entryPrice > 0 ? ((side === 'BUY' ? exitPrice - entryPrice : entryPrice - exitPrice) / entryPrice) * 100 : 0; const holdMs = new Date(exitTime).getTime() - new Date(entryTime).getTime(); const holdMinutes = Math.round(holdMs / 60000); const record = await prisma.tradeLog.upsert({ where: { tradeId }, update: { exitPrice, exitTime: new Date(exitTime), pnl, roi, holdMinutes, status: 'CLOSED', exitReason: exitReason || undefined, }, create: { tradeId, symbol, underlying: parsed.underlying, optionType: parsed.optionType, strike: parsed.strike, expiry: parsed.expiry, side, quantity, entryPrice, exitPrice, entryTime: new Date(entryTime), exitTime: new Date(exitTime), pnl, roi, holdMinutes, source, status: 'CLOSED', exitReason, }, }); logger.info(`[TRADE_LOG] Logged: ${symbol} ${side} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}% Hold=${holdMinutes}min`); return record; } catch (err) { // Silently handle dupes if (err.code === 'P2002') { logger.info(`[TRADE_LOG] Already logged: ${tradeId}`); return null; } logger.error(`[TRADE_LOG] Error: ${err.message}`); return null; } } // โ”€โ”€โ”€ Log an open trade (no exit yet) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function logOpenTrade({ tradeId, symbol, side, quantity, entryPrice, entryTime, source = 'manual' }) { try { const parsed = parseSymbol(symbol); if (!parsed) return null; // Check if already exists and CLOSED โ€” don't overwrite closed trades const existing = await prisma.tradeLog.findUnique({ where: { tradeId } }); if (existing) { if (existing.status === 'CLOSED') { logger.info(`[TRADE_LOG] Skip open: ${symbol} already CLOSED`); return existing; } // Already OPEN โ€” update qty/price if changed (e.g. partial fill) const record = await prisma.tradeLog.update({ where: { tradeId }, data: { quantity, entryPrice, entryTime: new Date(entryTime) }, }); logger.info(`[TRADE_LOG] Open (updated): ${symbol} ${side} qty=${quantity} @ $${entryPrice}`); return record; } const record = await prisma.tradeLog.create({ data: { tradeId, symbol, underlying: parsed.underlying, optionType: parsed.optionType, strike: parsed.strike, expiry: parsed.expiry, side, quantity, entryPrice, entryTime: new Date(entryTime), source, status: 'OPEN', }, }); logger.info(`[TRADE_LOG] Open (new): ${symbol} ${side} qty=${quantity} @ $${entryPrice}`); return record; } catch (err) { if (err.code === 'P2002') return null; logger.error(`[TRADE_LOG] logOpen error: ${err.message}`); return null; } } // โ”€โ”€โ”€ Close an existing open trade โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function closeTrade({ tradeId, exitPrice, exitTime, exitReason = null }) { try { const existing = await prisma.tradeLog.findUnique({ where: { tradeId } }); if (!existing || existing.status === 'CLOSED' || existing.status === 'EXPIRED') return null; const pnl = existing.side === 'BUY' ? (exitPrice - existing.entryPrice) * existing.quantity : (existing.entryPrice - exitPrice) * existing.quantity; const roi = existing.entryPrice > 0 ? ((existing.side === 'BUY' ? exitPrice - existing.entryPrice : existing.entryPrice - exitPrice) / existing.entryPrice) * 100 : 0; const holdMs = new Date(exitTime).getTime() - existing.entryTime.getTime(); const holdMinutes = Math.round(holdMs / 60000); const record = await prisma.tradeLog.update({ where: { tradeId }, data: { exitPrice, exitTime: new Date(exitTime), pnl, roi, holdMinutes, status: 'CLOSED', exitReason: exitReason || undefined, }, }); logger.info(`[TRADE_LOG] Closed: ${existing.symbol} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}%`); return record; } catch (err) { logger.error(`[TRADE_LOG] closeTrade error: ${err.message}`); return null; } } // โ”€โ”€โ”€ Get stats for period โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function getStats({ days, underlying } = {}) { const where = { status: { in: ['CLOSED', 'EXPIRED'] } }; if (days) { where.exitTime = { gte: new Date(Date.now() - days * 86400000) }; } if (underlying && underlying !== 'ALL') { where.underlying = underlying; } const trades = await prisma.tradeLog.findMany({ where, orderBy: { exitTime: 'desc' }, }); if (trades.length === 0) { return { totalPnl: 0, winRate: 0, totalTrades: 0, wins: 0, losses: 0, avgWin: 0, avgLoss: 0, profitFactor: 0, bestTrade: null, worstTrade: null, winStreak: 0, lossStreak: 0, currentStreak: 0, currentStreakType: null, avgHoldMinutes: 0, }; } const wins = trades.filter(t => t.pnl > 0); const losses = trades.filter(t => t.pnl <= 0); const totalPnl = trades.reduce((s, t) => s + (t.pnl || 0), 0); const totalWinPnl = wins.reduce((s, t) => s + t.pnl, 0); const totalLossPnl = losses.reduce((s, t) => s + Math.abs(t.pnl), 0); const avgWin = wins.length > 0 ? totalWinPnl / wins.length : 0; const avgLoss = losses.length > 0 ? -(totalLossPnl / losses.length) : 0; const profitFactor = totalLossPnl > 0 ? totalWinPnl / totalLossPnl : totalWinPnl > 0 ? Infinity : 0; // Best/worst const sorted = [...trades].sort((a, b) => (b.pnl || 0) - (a.pnl || 0)); const bestTrade = sorted[0] ? { symbol: sorted[0].symbol, pnl: sorted[0].pnl, roi: sorted[0].roi } : null; const worstTrade = sorted[sorted.length - 1] ? { symbol: sorted[sorted.length - 1].symbol, pnl: sorted[sorted.length - 1].pnl, roi: sorted[sorted.length - 1].roi } : null; // Streaks (from most recent) let winStreak = 0, lossStreak = 0, maxWinStreak = 0, maxLossStreak = 0; for (const t of trades) { // already ordered desc if (t.pnl > 0) { winStreak++; lossStreak = 0; } else { lossStreak++; winStreak = 0; } maxWinStreak = Math.max(maxWinStreak, winStreak); maxLossStreak = Math.max(maxLossStreak, lossStreak); } // Current streak (from most recent trade) let currentStreak = 0; const currentStreakType = trades[0]?.pnl > 0 ? 'WIN' : 'LOSS'; for (const t of trades) { const isWin = t.pnl > 0; if ((currentStreakType === 'WIN' && isWin) || (currentStreakType === 'LOSS' && !isWin)) { currentStreak++; } else { break; } } const avgHoldMinutes = Math.round(trades.reduce((s, t) => s + (t.holdMinutes || 0), 0) / trades.length); return { totalPnl: Math.round(totalPnl * 10000) / 10000, winRate: Math.round((wins.length / trades.length) * 1000) / 10, totalTrades: trades.length, wins: wins.length, losses: losses.length, avgWin: Math.round(avgWin * 10000) / 10000, avgLoss: Math.round(avgLoss * 10000) / 10000, profitFactor: profitFactor === Infinity ? 999 : Math.round(profitFactor * 100) / 100, bestTrade, worstTrade, winStreak: maxWinStreak, lossStreak: maxLossStreak, currentStreak, currentStreakType, avgHoldMinutes, }; } // โ”€โ”€โ”€ Get journal (paginated) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ async function getJournal({ days, underlying, limit = 50, offset = 0 } = {}) { const where = { status: { in: ['CLOSED', 'EXPIRED'] } }; if (days) { where.exitTime = { gte: new Date(Date.now() - days * 86400000) }; } if (underlying && underlying !== 'ALL') { where.underlying = underlying; } const [trades, total] = await Promise.all([ prisma.tradeLog.findMany({ where, orderBy: { exitTime: 'desc' }, take: limit, skip: offset, select: { id: true, symbol: true, underlying: true, optionType: true, strike: true, expiry: true, side: true, quantity: true, entryPrice: true, exitPrice: true, entryTime: true, exitTime: true, pnl: true, roi: true, holdMinutes: true, source: true, status: true, exitReason: true, }, }), prisma.tradeLog.count({ where }), ]); return { trades, total, limit, offset }; } // โ”€โ”€โ”€ Sync from Binance eAPI โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Fetches positions โ†’ for each symbol fetches userTrades โ†’ pairs BUY/SELL โ†’ saves // Also checks exercise history for expired options async function syncFromBinance() { const trading = require('./trading'); const results = { synced: 0, expired: 0, skipped: 0, errors: [] }; try { // 1. Get symbols from open positions + OPEN trades in DB (closed positions disappear from API) const positions = await trading.getPositions(); const posSymbols = (positions || []).map(p => p.symbol); const openTrades = await prisma.tradeLog.findMany({ where: { status: 'OPEN' }, select: { symbol: true, tradeId: true, entryPrice: true, entryTime: true, quantity: true, side: true }, }); const dbSymbols = [...new Set(openTrades.map(t => t.symbol))]; const symbols = [...new Set([...posSymbols, ...dbSymbols])]; // 2. Check exercise history for expired options (OPEN trades not in positions) const posSymbolSet = new Set(posSymbols); const expiredSymbols = dbSymbols.filter(s => !posSymbolSet.has(s)); if (expiredSymbols.length > 0) { logger.info(`[TRADE_LOG] Checking exercise history for ${expiredSymbols.length} possibly expired symbols`); try { // Fetch exercise history (last 30 days) const exerciseHistory = await trading.getExerciseHistory({ startTime: Date.now() - 30 * 86400000, limit: 100, }); // Index by symbol const exerciseMap = {}; for (const ex of (exerciseHistory || [])) { if (!exerciseMap[ex.symbol]) exerciseMap[ex.symbol] = []; exerciseMap[ex.symbol].push(ex); } // Close expired OPEN trades for (const sym of expiredSymbols) { const openForSym = openTrades.filter(t => t.symbol === sym); const exercises = exerciseMap[sym] || []; for (const trade of openForSym) { let exitReason = null; let exitPrice = 0; // intrinsic value per contract (what you receive back) let exitTime = null; const parsed = parseSymbol(sym); if (exercises.length > 0 && parsed) { // Found in exercise history โ€” calculate intrinsic value ourselves const ex = exercises[0]; const settlementPrice = parseFloat(ex.realStrikePrice || 0); const strike = parsed.strike; const unit = getContractUnit(parsed.underlying); exitTime = new Date(ex.expiryDate || Date.now()); // Calculate intrinsic value per contract let intrinsic = 0; if (parsed.optionType === 'CALL') { intrinsic = Math.max(settlementPrice - strike, 0) * unit; } else { intrinsic = Math.max(strike - settlementPrice, 0) * unit; } if (intrinsic > 0) { exitReason = 'EXERCISED'; exitPrice = intrinsic; // what you receive per contract } else { exitReason = 'EXPIRED_WORTHLESS'; exitPrice = 0; } logger.info(`[TRADE_LOG] Exercise calc: ${sym} settlement=$${settlementPrice} strike=$${strike} type=${parsed.optionType} unit=${unit} intrinsic=$${intrinsic.toFixed(4)}`); } else if (parsed) { // Not in exercise history โ€” check if expiry date has passed const y = 2000 + parseInt(parsed.expiry.slice(0, 2)); const m = parseInt(parsed.expiry.slice(2, 4)) - 1; const d = parseInt(parsed.expiry.slice(4, 6)); const expiryDate = new Date(y, m, d, 8, 0, 0); // 08:00 UTC expiry if (expiryDate < new Date()) { exitReason = 'EXPIRED_WORTHLESS'; exitPrice = 0; exitTime = expiryDate; } } if (exitReason && exitTime) { // PnL: for BUY, profit = exitPrice - entryPrice; for SELL, reversed const pnl = trade.side === 'BUY' ? (exitPrice - trade.entryPrice) * trade.quantity : (trade.entryPrice - exitPrice) * trade.quantity; const roi = trade.entryPrice > 0 ? ((trade.side === 'BUY' ? exitPrice - trade.entryPrice : trade.entryPrice - exitPrice) / trade.entryPrice) * 100 : 0; const holdMs = exitTime.getTime() - new Date(trade.entryTime).getTime(); const holdMinutes = Math.round(holdMs / 60000); await prisma.tradeLog.update({ where: { tradeId: trade.tradeId }, data: { exitPrice, exitTime, pnl, roi, holdMinutes, status: exitReason === 'EXERCISED' ? 'CLOSED' : 'EXPIRED', exitReason, }, }); logger.info(`[TRADE_LOG] ${exitReason}: ${sym} exitPrice=$${exitPrice.toFixed(4)} PnL=$${pnl.toFixed(4)} ROI=${roi.toFixed(1)}%`); results.expired++; } } } } catch (err) { logger.warn(`[TRADE_LOG] Exercise history check failed: ${err.message}`); results.errors.push(`exercise: ${err.message}`); } } // 3. Sync active symbols via userTrades (existing logic) if (symbols.length === 0 && expiredSymbols.length === 0) { logger.info('[TRADE_LOG] Sync: no positions or open trades found'); return results; } const activeSymbols = symbols.filter(s => posSymbolSet.has(s) || !expiredSymbols.includes(s)); logger.info(`[TRADE_LOG] Sync: checking ${activeSymbols.length} active symbols (${posSymbols.length} positions)`); for (const sym of activeSymbols) { try { const trades = await trading.getTradeHistory(sym, 100); if (!trades || trades.length === 0) continue; // Separate BUYs and SELLs, sorted by time (FIFO) const buys = trades.filter(t => t.side === 'BUY').sort((a, b) => a.time - b.time); const sells = trades.filter(t => t.side === 'SELL').sort((a, b) => a.time - b.time); // Qty-aware FIFO matching: SELLs consume BUY quantities let sellIdx = 0; let sellRemaining = 0; for (const buy of buys) { const tradeId = `binance-${buy.tradeId || buy.id}`; let buyQty = parseFloat(buy.quantity); const entryPrice = parseFloat(buy.price); const entryTime = new Date(buy.time); let totalExitQty = 0; let weightedExitPrice = 0; let lastExitTime = null; while (buyQty > 1e-10 && sellIdx < sells.length) { if (sellRemaining <= 0) { sellRemaining = parseFloat(sells[sellIdx].quantity); } const consumed = Math.min(buyQty, sellRemaining); const sellPrice = parseFloat(sells[sellIdx].price); totalExitQty += consumed; weightedExitPrice += consumed * sellPrice; lastExitTime = new Date(sells[sellIdx].time); buyQty -= consumed; sellRemaining -= consumed; if (sellRemaining <= 1e-10) { sellIdx++; sellRemaining = 0; } } if (totalExitQty > 1e-10) { const avgExitPrice = weightedExitPrice / totalExitQty; await logTrade({ tradeId, symbol: sym, side: 'BUY', quantity: parseFloat(buy.quantity), entryPrice, exitPrice: avgExitPrice, entryTime, exitTime: lastExitTime, source: 'sync', exitReason: 'MANUAL_CLOSE', }); results.synced++; } else { await logOpenTrade({ tradeId, symbol: sym, side: 'BUY', quantity: parseFloat(buy.quantity), entryPrice, entryTime, source: 'sync', }); results.synced++; } } for (let i = sellIdx; i < sells.length; i++) { const sell = sells[i]; const tradeId = `binance-sell-${sell.tradeId || sell.id}`; await logOpenTrade({ tradeId, symbol: sym, side: 'SELL', quantity: parseFloat(sell.quantity), entryPrice: parseFloat(sell.price), entryTime: new Date(sell.time), source: 'sync', }); results.synced++; } } catch (err) { results.errors.push(`${sym}: ${err.message}`); } } logger.info(`[TRADE_LOG] Sync done: ${results.synced} synced, ${results.expired} expired, ${results.errors.length} errors`); } catch (err) { logger.error(`[TRADE_LOG] Sync failed: ${err.message}`); results.errors.push(err.message); } return results; } module.exports = { parseSymbol, logTrade, logOpenTrade, closeTrade, getStats, getJournal, syncFromBinance, };