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