← Back
'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,
};

📜 Git History

e841599feat: expired options tracking + exercise history + exit reason fix3 months ago
00c3a6cfix: trade journal sync — closed trades now appear correctly3 months ago
43817b9feat: trading stats + trade journal — Binance sync, stats bar, journal table3 months ago
Show last diff
Loading...