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