โ† ะะฐะทะฐะด
'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 };