← Back
'use strict';

const prisma = require('./db');
const logger = require('../utils/logger');

// Check signals that need outcome tracking and fill in spot prices.
// Called from slow update cycle (every 5 min).
//
// v2: Calculate realistic option P&L using delta approximation:
//   option_pnl% = (delta × spot_move) / entry_premium × 100
// Falls back to directional spot P&L when delta/premium unavailable.

async function trackOutcomes(spotPrices) {
  if (!spotPrices || Object.keys(spotPrices).length === 0) return;

  const now = Date.now();
  let updated = 0;

  // ─── 1h checkpoint ─────────────────────────────────
  try {
    const need1h = await prisma.signalLog.findMany({
      where: {
        spotAfter1h: null,
        createdAt: { lte: new Date(now - 60 * 60 * 1000) },
      },
      take: 50,
    });

    for (const sig of need1h) {
      const spot = spotPrices[sig.underlying];
      if (!spot) continue;
      const pnl = calcOptionPnl(sig, spot);
      await prisma.signalLog.update({
        where: { id: sig.id },
        data: { spotAfter1h: spot, pnlPct1h: pnl },
      });
      updated++;
    }
  } catch (err) {
    logger.error(`outcomeTracker 1h error: ${err.message}`);
  }

  // ─── 4h checkpoint ─────────────────────────────────
  try {
    const need4h = await prisma.signalLog.findMany({
      where: {
        spotAfter4h: null,
        spotAfter1h: { not: null },
        createdAt: { lte: new Date(now - 4 * 60 * 60 * 1000) },
      },
      take: 50,
    });

    for (const sig of need4h) {
      const spot = spotPrices[sig.underlying];
      if (!spot) continue;
      const pnl = calcOptionPnl(sig, spot);
      await prisma.signalLog.update({
        where: { id: sig.id },
        data: { spotAfter4h: spot, pnlPct4h: pnl },
      });
      updated++;
    }
  } catch (err) {
    logger.error(`outcomeTracker 4h error: ${err.message}`);
  }

  // ─── 24h checkpoint ────────────────────────────────
  try {
    const need24h = await prisma.signalLog.findMany({
      where: {
        spotAfter24h: null,
        spotAfter4h: { not: null },
        createdAt: { lte: new Date(now - 24 * 60 * 60 * 1000) },
      },
      take: 50,
    });

    for (const sig of need24h) {
      const spot = spotPrices[sig.underlying];
      if (!spot) continue;
      const pnl = calcOptionPnl(sig, spot);
      const outcome = pnl > 0 ? 'WIN' : pnl < 0 ? 'LOSS' : 'NEUTRAL';
      await prisma.signalLog.update({
        where: { id: sig.id },
        data: { spotAfter24h: spot, pnlPct24h: pnl, outcome },
      });
      updated++;
    }
  } catch (err) {
    logger.error(`outcomeTracker 24h error: ${err.message}`);
  }

  if (updated > 0) {
    logger.info(`outcomeTracker: updated ${updated} signal outcomes`);
  }
}

/**
 * Calculate realistic option P&L %.
 *
 * For directional (BULLISH/BEARISH):
 *   option_pnl% ≈ (delta × spot_change) / premium × 100
 *
 * For NEUTRAL (straddle/strangle):
 *   Each leg calculated independently with its own delta and premium.
 *   Call leg profits from spot up, put leg profits from spot down.
 *   Total P&L = (callLegPnl + putLegPnl) / totalPremium × 100
 *   This correctly shows LOSS when spot doesn't move enough.
 *
 * Safeguards:
 * - Infer direction from strategy name if stored as NEUTRAL (legacy fix)
 * - Cap PnL: -100% (can't lose more than premium) to +500% (delta approx breaks beyond)
 * - Min premium $0.10 / delta 0.03 threshold (avoid micro-division)
 */
function calcOptionPnl(sig, spotNow) {
  if (!sig.spotAtSignal || sig.spotAtSignal === 0) return 0;

  const params = safeParseJson(sig.parameters);
  const delta = parseFloat(params.delta) || 0;
  const premium = parseFloat(params.costUsd) || 0;
  const spotMove = spotNow - sig.spotAtSignal;

  // Fix legacy direction: "Buy Call" stored as NEUTRAL → should be BULLISH
  let direction = sig.direction || 'NEUTRAL';
  if (direction === 'NEUTRAL') {
    const st = (sig.strategy || '').toUpperCase();
    if (st.includes('CALL') || st.includes('BULL')) direction = 'BULLISH';
    else if (st.includes('PUT') || st.includes('BEAR')) direction = 'BEARISH';
  }

  // ─── NEUTRAL: Straddle/Strangle — two legs with independent deltas ───
  if (direction === 'NEUTRAL') {
    const callDelta = parseFloat(params.callDelta) || 0;
    const putDelta = parseFloat(params.putDelta) || 0;
    const callPrem = parseFloat(params.callPremium) || 0;
    const putPrem = parseFloat(params.putPremium) || 0;
    const ratioCall = parseFloat(params.ratioCall) || 1;
    const ratioPut = parseFloat(params.ratioPut) || 1;

    if (callDelta >= 0.03 && putDelta >= 0.03 && callPrem > 0 && putPrem > 0) {
      // Call leg gains when spot goes up: callDelta × spotMove
      // Put leg gains when spot goes down: putDelta × (-spotMove)
      const callLegPnl = Math.max(callDelta * spotMove * ratioCall, -callPrem * ratioCall);
      const putLegPnl = Math.max(putDelta * (-spotMove) * ratioPut, -putPrem * ratioPut);
      const totalCost = callPrem * ratioCall + putPrem * ratioPut;
      const pnlPct = ((callLegPnl + putLegPnl) / totalCost) * 100;
      return clampPnl(pnlPct);
    }

    // Fallback for NEUTRAL without leg data:
    // Straddle breakeven ≈ premium/spot. Without premium data, return 0 (unknown).
    if (premium > 0) {
      // Rough: straddle profits when |move| > premium. Assume ATM delta ~0.5 each leg.
      const callLegPnl = Math.max(0.5 * spotMove, -premium / 2);
      const putLegPnl = Math.max(0.5 * (-spotMove), -premium / 2);
      return clampPnl(((callLegPnl + putLegPnl) / premium) * 100);
    }

    return 0; // No data to calculate — don't fake a WIN
  }

  // ─── Directional: Call or Put ───
  if (delta >= 0.03 && premium >= 0.10) {
    const sign = direction === 'BEARISH' ? -1 : 1;
    const optPnl = (delta * sign * spotMove) / premium * 100;
    return clampPnl(optPnl);
  }

  // Fallback: simple directional spot P&L (when delta/premium too small or absent)
  const rawPct = ((spotNow - sig.spotAtSignal) / sig.spotAtSignal) * 100;
  if (direction === 'BEARISH') return clampPnl(-rawPct);
  return clampPnl(rawPct);
}

// Cap PnL: can't lose more than premium (-100%), cap gains at +500% (delta approx unreliable beyond)
function clampPnl(pnl) {
  return parseFloat(Math.max(-100, Math.min(500, pnl)).toFixed(2));
}

function safeParseJson(str) {
  try { return JSON.parse(str || '{}'); } catch { return {}; }
}

module.exports = { trackOutcomes };

📜 Git History

e78fc62feat: auto-trading for Gamma Play + fix fake 100% WR bug10 weeks ago
519066ffix: backtest PnL calculation — direction inference, caps, min thresholds3 months ago
612398cfeat: signal reliability overhaul — OI data, liquidity gate, realistic PnL tracking3 months ago
e12be00feat: Historical Signals Backtest tracking (Task 2.4)3 months ago
Show last diff
Loading...