← Назад
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 };