← Назадfunction getDte(expiryStr) {
if (!expiryStr || expiryStr.length !== 6) return null;
const year = 2000 + parseInt(expiryStr.substring(0, 2));
const month = parseInt(expiryStr.substring(2, 4)) - 1;
const day = parseInt(expiryStr.substring(4, 6));
const expDate = new Date(Date.UTC(year, month, day, 8, 0, 0));
return Math.max(0, Math.ceil((expDate - Date.now()) / 86400000));
}
function findUnusualVolume(optionsData, spotPrices, cfg = {}) {
const { minVoiRatio = 3, minVolume = 1, limit = 30,
underlying = null, minPremiumUsd = 5000 } = cfg;
const results = [];
for (const opt of optionsData) {
if (underlying && !opt.symbol.startsWith(underlying))
continue;
const volume = parseFloat(opt.volume || 0);
const oi = parseFloat(opt.openInterest || opt.oi || 0);
if (volume < minVolume) continue;
if (oi < 20) continue; // Liquidity gate: skip thin OI
const voiRatio = oi > 0 ? volume / oi : 0;
if (voiRatio < minVoiRatio) continue;
const parts = opt.symbol.split('-');
const asset = parts[0];
const expiry = parts[1];
// Phase 8: Strict DTE for Volume signals (avoid Theta burn, require 7-30 days)
const dte = getDte(expiry);
if (dte === null || dte < 7 || dte > 30) continue;
const strike = parseFloat(parts[2] || opt.strikePrice);
const spot = spotPrices[asset] || 0;
const lastPrice = parseFloat(opt.lastPrice || opt.close || 0);
const premiumUsd = lastPrice * volume;
if (premiumUsd < minPremiumUsd) continue;
const isCall = opt.side === 'CALL'
|| opt.symbol.includes('-C');
const isItm = isCall ? spot > strike : spot < strike;
const distPct = spot > 0 ? ((strike - spot) / spot) * 100 : 0;
let severity = 'LOW';
if (voiRatio > 10) severity = 'EXTREME';
else if (voiRatio > 5) severity = 'HIGH';
else if (voiRatio > 3) severity = 'MEDIUM';
let direction = isCall ? 'BULLISH' : 'BEARISH';
let confidence = 'MEDIUM';
if (isItm) {
confidence = 'HIGH';
direction = isCall ? 'STRONG_BULLISH' : 'STRONG_BEARISH';
}
if (premiumUsd > 100000) confidence = 'HIGH';
const typeLabel = isCall ? 'Call' : 'Put';
const biasLabel = isCall ? 'рост 📈' : 'падение 📉';
const descs = {
EXTREME: `🐋 Экстремальная активность! Объём ${voiRatio.toFixed(0)}× от OI. Крупная ставка на ${biasLabel} ($${premiumUsd.toLocaleString('en-US', {maximumFractionDigits: 0})}).`,
HIGH: `🐋 Высокая активность ${typeLabel}. V/OI ${voiRatio.toFixed(1)}×. Премия $${premiumUsd.toLocaleString('en-US', {maximumFractionDigits: 0})}. Ставка на ${biasLabel}.`,
MEDIUM: `📊 Заметная активность ${typeLabel}. V/OI ${voiRatio.toFixed(1)}×. Кто-то набирает позицию.`,
LOW: `📊 Умеренная активность ${typeLabel}. V/OI ${voiRatio.toFixed(1)}×.`,
};
results.push({
symbol: opt.symbol, underlying: asset, strike,
type: isCall ? 'CALL' : 'PUT', expiry: parts[1],
volume, openInterest: oi,
voiRatio: parseFloat(voiRatio.toFixed(2)),
lastPrice,
premiumUsd: parseFloat(premiumUsd.toFixed(2)),
spotPrice: spot, moneyness: isItm ? 'ITM' : 'OTM',
distancePercent: parseFloat(distPct.toFixed(2)),
delta: opt.delta ? parseFloat(opt.delta) : null,
iv: opt.markIV ? parseFloat(opt.markIV) : null,
signal: {
severity, direction, confidence,
description: descs[severity]
},
});
}
results.sort((a, b) => b.voiRatio - a.voiRatio);
return {
count: Math.min(results.length, limit),
totalFound: results.length,
data: results.slice(0, limit)
};
}
module.exports = { findUnusualVolume };