← Back
'use strict';

const prisma = require('./db');
const { cache, parseSymbol } = require('./cache');
const { sendMessage } = require('./telegram');
const { getAtmIv, calculateIvRankAndPercentile } = require('../analysis/ivAnalysis');
const { analyzeAllPCRatios } = require('../analysis/putCallRatio');
const { calculateAllMaxPain } = require('../analysis/maxPain');
const { analyzeSkew } = require('../analysis/ivSkew');
const { findUnusualVolume } = require('../analysis/unusualVolume');
const { getAtmIvHistory } = require('./ivHistory');
const { mergeGreeksIntoOptions } = require('../utils/mergeGreeks');
const logger = require('../utils/logger');

// In-memory store: previous metric values for crosses_above/crosses_below detection
const previousValues = new Map(); // alertId → last numeric value

// ─── Metric Computation ──────────────────────────────────

async function computeMetric(alert, data, spotPrices) {
  const { underlying, metric } = alert;
  const underlyings = underlying === 'ALL'
    ? Object.keys(spotPrices)
    : [underlying];

  const results = [];

  for (const u of underlyings) {
    const spot = spotPrices[u];
    if (!spot) continue;

    const uData = data.filter(o => o.symbol.startsWith(u + '-'));
    let value = null;

    switch (metric) {
      case 'spot_price': {
        value = spot;
        break;
      }

      case 'atm_iv': {
        const atm = getAtmIv(data, u, spot);
        if (atm) value = atm.iv * 100; // convert to %
        break;
      }

      case 'iv_rank':
      case 'iv_percentile': {
        const atm = getAtmIv(data, u, spot);
        if (!atm || atm.iv === 0) break;
        const history = await getAtmIvHistory(u, 30);
        if (history.length < 10) break;
        const ranked = calculateIvRankAndPercentile(atm.iv, history);
        value = metric === 'iv_rank' ? ranked.ivRank : ranked.ivPercentile;
        break;
      }

      case 'put_call_ratio':
      case 'put_call_ratio_oi': {
        const expiries = [...new Set(uData.map(o => o.symbol.split('-')[1]))];
        if (expiries.length === 0) break;
        // Use nearest expiry for relevance
        const nearest = expiries.sort()[0];
        const pcr = analyzeAllPCRatios(data, u);
        if (pcr.expiries && pcr.expiries.length > 0) {
          const first = pcr.expiries[0];
          value = metric === 'put_call_ratio' ? first.volumeRatio : first.oiRatio;
        }
        break;
      }

      case 'unusual_volume': {
        const uvResult = findUnusualVolume(data, spotPrices, { minVoiRatio: 2, limit: 50 });
        const uItems = uvResult.data.filter(item => item.underlying === u);
        // Return max V/OI ratio for this underlying
        if (uItems.length > 0) {
          value = Math.max(...uItems.map(item => item.voiRatio));
        } else {
          value = 0;
        }
        break;
      }

      case 'max_pain_distance': {
        const allMp = calculateAllMaxPain(data, spotPrices);
        const uMp = allMp.filter(mp => mp.underlying === u);
        if (uMp.length > 0) {
          // Use nearest expiry
          value = Math.abs(uMp[0].distancePercent);
        }
        break;
      }

      case 'iv_skew': {
        const skews = analyzeSkew(data, u, spot);
        if (skews.length > 0) {
          value = skews[0].skew25d; // nearest expiry
        }
        break;
      }

      case 'oi_change': {
        // OI change requires previous snapshot — use 0 if no history
        // This metric will be more useful once we track OI snapshots
        value = 0;
        break;
      }
    }

    if (value !== null && !isNaN(value)) {
      results.push({ underlying: u, value });
    }
  }

  return results;
}

// ─── Condition Evaluation ─────────────────────────────────

function evaluateCondition(condition, currentValue, threshold, alertId) {
  const prevValue = previousValues.get(alertId);

  switch (condition) {
    case 'gt':  return currentValue > threshold;
    case 'lt':  return currentValue < threshold;
    case 'gte': return currentValue >= threshold;
    case 'lte': return currentValue <= threshold;
    case 'eq':  return Math.abs(currentValue - threshold) < 0.01;
    case 'crosses_above':
      if (prevValue === undefined) return false;
      return prevValue <= threshold && currentValue > threshold;
    case 'crosses_below':
      if (prevValue === undefined) return false;
      return prevValue >= threshold && currentValue < threshold;
    default:
      return false;
  }
}

// ─── Cooldown Check ───────────────────────────────────────

function isCoolingDown(alert) {
  if (!alert.lastTriggeredAt) return false;
  const elapsed = Date.now() - new Date(alert.lastTriggeredAt).getTime();
  return elapsed < alert.cooldownMinutes * 60 * 1000;
}

// ─── Format Alert Message ─────────────────────────────────

function formatAlertMessage(alert, underlying, value) {
  const condLabel = {
    gt: '>', lt: '<', gte: '≥', lte: '≤', eq: '=',
    crosses_above: 'crossed above', crosses_below: 'crossed below',
  };

  const metricLabel = {
    iv_rank: 'IV Rank', iv_percentile: 'IV Percentile',
    put_call_ratio: 'P/C Ratio (Vol)', put_call_ratio_oi: 'P/C Ratio (OI)',
    unusual_volume: 'V/OI Ratio', max_pain_distance: 'Max Pain Distance',
    atm_iv: 'ATM IV', oi_change: 'OI Change', iv_skew: 'IV Skew',
    spot_price: 'Spot Price',
  };

  const unitLabel = {
    iv_rank: '%', iv_percentile: '%', atm_iv: '%',
    max_pain_distance: '%', oi_change: '%',
    spot_price: '$', unusual_volume: 'x',
  };

  const mLabel = metricLabel[alert.metric] || alert.metric;
  const unit = unitLabel[alert.metric] || '';
  const cLabel = condLabel[alert.condition] || alert.condition;
  const formattedValue = typeof value === 'number'
    ? (value >= 1000 ? value.toLocaleString('en') : value.toFixed(2))
    : value;

  return [
    `🔔 <b>Alert: ${alert.name}</b>`,
    ``,
    `📊 <b>${underlying}</b> ${mLabel}: <b>${formattedValue}${unit}</b>`,
    `Condition: ${mLabel} ${cLabel} ${alert.threshold}${unit}`,
    ``,
    `<code>${new Date().toISOString()}</code>`,
  ].join('\n');
}

// ─── Main Check Function ──────────────────────────────────
// Called from scheduler.slowUpdate() every 5 minutes

async function checkCustomAlerts(spotPrices) {
  if (!cache.options || !spotPrices) return;

  let alerts;
  try {
    alerts = await prisma.customAlert.findMany({ where: { enabled: true } });
  } catch (err) {
    logger.error(`alertChecker: DB query error: ${err.message}`);
    return;
  }

  if (alerts.length === 0) return;

  const data = mergeGreeksIntoOptions(cache.options, cache.greeks);
  let triggered = 0;

  for (const alert of alerts) {
    try {
      // Skip if cooling down
      if (isCoolingDown(alert)) continue;

      const metricResults = await computeMetric(alert, data, spotPrices);

      for (const { underlying, value } of metricResults) {
        const key = `${alert.id}_${underlying}`;
        const isTriggered = evaluateCondition(alert.condition, value, alert.threshold, key);

        // Update previous value for crosses_above/crosses_below
        previousValues.set(key, value);

        if (!isTriggered) continue;

        // ─── TRIGGERED! ────────────────────────────
        triggered++;
        const message = formatAlertMessage(alert, underlying, value);

        // Save trigger to DB
        try {
          await prisma.alertTrigger.create({
            data: {
              alertId: alert.id,
              value,
              message,
            },
          });
        } catch (err) {
          logger.error(`alertChecker: trigger save error: ${err.message}`);
        }

        // Update lastTriggeredAt
        try {
          await prisma.customAlert.update({
            where: { id: alert.id },
            data: { lastTriggeredAt: new Date() },
          });
        } catch (err) {
          logger.error(`alertChecker: update lastTriggeredAt error: ${err.message}`);
        }

        // Send Telegram notification
        if (alert.telegramEnabled) {
          try {
            await sendMessage(message);
            await new Promise(r => setTimeout(r, 400)); // Telegram rate limit
          } catch (err) {
            logger.error(`alertChecker: TG send error: ${err.message}`);
          }
        }

        // Send Push notification
        if (alert.pushEnabled) {
          try {
            const { broadcastPushNotification } = require('../routes/notifications');
            await broadcastPushNotification({
              title: `🔔 ${alert.name}`,
              body: `${underlying} ${alert.metric}: ${typeof value === 'number' ? value.toFixed(2) : value}`,
              icon: '/pwa-192x192.png',
              data: { url: '/?tab=alerts' },
            });
          } catch (err) {
            logger.error(`alertChecker: push error: ${err.message}`);
          }
        }

        logger.info(`🔔 Custom alert triggered: #${alert.id} "${alert.name}" — ${underlying} ${alert.metric}=${typeof value === 'number' ? value.toFixed(2) : value}`);

        // Only trigger once per underlying per check cycle (avoid spam)
        break;
      }
    } catch (err) {
      logger.error(`alertChecker: error checking alert #${alert.id}: ${err.message}`);
    }
  }

  if (triggered > 0) {
    logger.info(`alertChecker: ${triggered} alert(s) triggered out of ${alerts.length} active`);
  }
}

module.exports = { checkCustomAlerts };

📜 Git History

f8910e1feat: Custom Alerts system (Task 2.3)3 months ago
Show last diff
Loading...