← Назад
const { fetchOptions, fetchGreeks, fetchSpotPrices, fetchOpenInterest, fetchContractUnits } = require('./binance'); const { cache } = require('./cache'); const config = require('../config'); const { saveAtmIvSnapshots, buildIvContexts } = require('./ivHistory'); const { processSignals } = require('./alerts'); const { checkCustomAlerts } = require('./alertChecker'); const { logSignals } = require('./signalLogger'); const { trackOutcomes } = require('./outcomeTracker'); const { analyzeAllStrategies } = require('../analysis/strategies'); const { findUnusualVolume } = require('../analysis/unusualVolume'); const { findGammaPlays } = require('../analysis/gammaPlay'); const { analyzeSkew } = require('../analysis/ivSkew'); const { fetchSpotTrends, trendLabel } = require('../analysis/spotTrend'); const { mergeGreeksIntoOptions } = require('../utils/mergeGreeks'); const { monitorTrades, openTradeWithTP, getTrades, clampPrice, getMaxPrice } = require('./tradeManager'); const { getPositions } = require('./trading'); const prisma = require('./db'); const logger = require('../utils/logger'); let broadcastFn = null; let recentSignalIds = new Set(); let fastTimer = null; let slowTimer = null; function setBroadcast(fn) { broadcastFn = fn; } // ─── FAST CYCLE (every 30s) ─────────────────────────────── // Fetches fresh prices + Greeks, updates cache, broadcasts to WS clients // Lightweight — no DB writes, no heavy analysis async function fastUpdate() { const start = Date.now(); try { const [options, greeks, spotPrices] = await Promise.all([ fetchOptions(), fetchGreeks(), fetchSpotPrices(), ]); // Fetch OI separately (needs options for expiry list) if (options) { try { const oiMap = await fetchOpenInterest(options); for (const opt of options) { if (oiMap[opt.symbol] !== undefined) { opt.openInterest = oiMap[opt.symbol]; } } } catch (err) { logger.error(`OI merge error: ${err.message}`); } } if (options) cache.options = options; if (greeks) { cache.greeks = {}; greeks.forEach(g => { cache.greeks[g.symbol] = g; }); } if (spotPrices) cache.spotPrices = spotPrices; cache.lastUpdate = new Date().toISOString(); if (broadcastFn) broadcastFn(); // Monitor active trades (check fills → place TP) try { await monitorTrades(); } catch (err) { logger.error(`Trade monitor error: ${err.message}`); } const ms = Date.now() - start; logger.info(`Fast update done (${ms}ms) — ${(options || []).length} options`); } catch (err) { logger.error(`Fast update error: ${err.message}`); } } // ─── SLOW CYCLE (every 5 min) ───────────────────────────── // Heavy tasks: IV snapshots to DB, strategy analysis, signal alerts // Runs after a fast update so data is fresh async function slowUpdate() { if (!cache.options) return; const start = Date.now(); let spotPrices; try { spotPrices = cache.spotPrices || await fetchSpotPrices(); } catch (err) { logger.error(`slowUpdate fetchSpotPrices error: ${err.message}`); return; } const data = mergeGreeksIntoOptions(cache.options, cache.greeks); // Save ATM IV snapshots for real IV Rank history try { await saveAtmIvSnapshots(data, spotPrices); } catch (err) { logger.error(`IV snapshot error: ${err.message}`); } // Build real IV contexts (from DB history) and collect signals for alerts try { const [ivContexts, spotTrends] = await Promise.all([ buildIvContexts(data, spotPrices), fetchSpotTrends(), ]); cache.spotTrends = spotTrends; // Cache for API access const strategySignals = analyzeAllStrategies(data, spotPrices, ivContexts, 'ALL', spotTrends); // Also collect unusual-volume and gamma-play signals const uvResult = findUnusualVolume(data, spotPrices, { minVoiRatio: 5, limit: 12 }); const uvSignals = uvResult.data .filter(item => item.signal.severity === 'HIGH' || item.signal.severity === 'EXTREME') .map(item => { const isBull = item.signal.direction.includes('BULL'); const biasEmoji = isBull ? '📈' : '📉'; const biasLabel = isBull ? 'рост' : 'падение'; const premiumFmt = item.premiumUsd >= 1000 ? `$${(item.premiumUsd / 1000).toFixed(0)}K` : `$${item.premiumUsd.toFixed(0)}`; const trend = spotTrends[item.underlying]; const trendInfo = trend ? ` Спот ${trend.change4h >= 0 ? '+' : ''}${trend.change4h}% (4ч).` : ''; return { id: `unusual_vol_${item.symbol.replace(/-/g, '_').toLowerCase()}`, strategy: 'Unusual Volume', signal: item.signal.direction, underlying: item.underlying, direction: isBull ? 'BULLISH' : 'BEARISH', confidence: item.signal.severity === 'EXTREME' ? 90 : 75, severity: item.signal.severity, parameters: { symbol: item.symbol, voiRatio: item.voiRatio, costUsd: item.lastPrice, delta: Math.abs(item.delta || 0.5).toFixed(3), totalWhalePremium: item.premiumUsd, spot: item.spotPrice, ...(trend ? { spotTrend: { bias: trend.bias, change1h: trend.change1h, change4h: trend.change4h } } : {}), }, description: `🐋 ${item.underlying} — кит вложил ${premiumFmt} в ${item.type} $${item.strike} (объём ${item.voiRatio.toFixed(0)}× от OI).${trendInfo} Крупная ставка на ${biasLabel}. Подтверди спотом перед входом.`, rationale: `🐋 Кит влил ${premiumFmt} в ${item.type} ${item.underlying}.${trendInfo} V/OI ${item.voiRatio.toFixed(1)}×. Ставка на ${biasLabel}.`, tooltip: `🐋 КИТОВЫЙ СЛЕД: ${premiumFmt} влито в ${item.type} ${item.underlying} $${item.strike}.\nV/OI ${item.voiRatio.toFixed(1)}× — объём в ${item.voiRatio.toFixed(0)} раз превышает OI.${trendInfo}\n⚠️ Не копируй слепо — используй как подтверждение!`, }; }); const gammaPlays = findGammaPlays(data, spotPrices, { maxDte: 3, maxDistancePercent: 5, limit: 20 }); // Dedup: keep only best gamma per underlying+expiry+strike (no CALL+PUT duplicates) const gammaDeduped = new Map(); for (const g of gammaPlays) { if (g.signal.strength !== 'EXTREME' && g.signal.strength !== 'HIGH') continue; const key = `${g.underlying}_${g.expiry}_${g.strike}`; const existing = gammaDeduped.get(key); if (!existing || g.gamma > existing.gamma) { gammaDeduped.set(key, g); } } const gammaSignals = [...gammaDeduped.values()].slice(0, 5).map(g => { const expiryLabel = g.dte <= 1 ? 'СЕГОДНЯ' : `через ${Math.round(g.dte)} дн.`; const isCall = g.type === 'CALL'; const strikeFmt = g.strike >= 100 ? `$${Math.round(g.strike).toLocaleString('en-US')}` : `$${g.strike}`; const moveLabel = g.premiumMoveFor1Pct > 0 ? ` При движении 1% спота премия +${Math.round(g.premiumMoveFor1Pct)}%.` : ''; const trend = spotTrends[g.underlying]; const trendInfo = trend ? ` Спот ${trend.change1h >= 0 ? '+' : ''}${trend.change1h}%/1ч, EMA ${trend.emaDirection === 'UP' ? '↑' : trend.emaDirection === 'DOWN' ? '↓' : '→'}.` : ''; // Trend filter: penalize if spot goes against direction let conf = g.signal.strength === 'EXTREME' ? 90 : 75; let trendWarn = ''; if (trend) { const up = trend.emaDirection === 'UP' || trend.change1h > 0.3; const down = trend.emaDirection === 'DOWN' || trend.change1h < -0.3; if (isCall && down) { conf -= 15; trendWarn = ' ⚠️ Спот падает — CALL рискован!'; } else if (!isCall && up) { conf -= 15; trendWarn = ' ⚠️ Спот растёт — PUT рискован!'; } else if ((isCall && up) || (!isCall && down)) { conf += 5; } } const thetaVal = g.theta ? Math.abs(g.theta) : 0; const thetaWarn = thetaVal > 0 ? `⚠️ Theta -$${thetaVal.toFixed(4)}/день` : `⚠️ Theta сжигает`; return { id: `gamma_play_${g.underlying.toLowerCase()}_${g.expiry}_${g.strike}`, strategy: 'Gamma Play', signal: `GAMMA_${g.signal.strength}`, underlying: g.underlying, direction: isCall ? 'BULLISH' : 'BEARISH', confidence: conf, severity: g.signal.strength, parameters: { symbol: g.symbol, dte: g.dte, gamma: g.gamma, theta: g.theta, delta: Math.abs(g.delta || 0.5).toFixed(3), costUsd: g.lastPrice, distancePercent: g.distancePercent, ...(trend ? { spotTrend: { bias: trend.bias, change1h: trend.change1h, change4h: trend.change4h } } : {}), }, description: `⚡ ${g.underlying} ${g.type} ${strikeFmt} — экспирация ${expiryLabel}, цена ${g.distancePercent.toFixed(1)}% от страйка.${moveLabel}${trendInfo}${trendWarn} ${thetaWarn}`, rationale: `⚡ ${g.underlying} ${g.type} ${strikeFmt} — экспирация ${expiryLabel}! Цена ${g.distancePercent.toFixed(1)}% от страйка.${moveLabel}${trendInfo} Гамма взрыв возможен — закрывай быстро!`, }; }).filter(s => s.confidence >= 70); // IV Skew signals (was missing from scheduler — only in dashboard.js) const sevToConf = { EXTREME: 90, HIGH: 75, MEDIUM: 58, LOW: 40 }; const ivSkewSignals = []; for (const [asset, spot] of Object.entries(spotPrices)) { const skewResults = analyzeSkew(data, asset, spot); for (const s of skewResults) { if (s.signal !== 'BULLISH' && s.signal !== 'STRONG_BEARISH') continue; const isBullSkew = s.signal === 'BULLISH'; const skewDir = isBullSkew ? 'Коллы дороже Путов — рынок ждёт роста' : 'Путы дороже Коллов — хеджирование/страх'; const skewAction = isBullSkew ? 'Рассмотри покупку Call' : 'Рассмотри покупку Put или хеджирование'; const spotFmt = spot >= 100 ? `$${Math.round(spot).toLocaleString('en-US')}` : `$${spot.toFixed(4)}`; const skTrend = spotTrends[asset]; const skTrendInfo = skTrend ? ` Спот ${skTrend.change4h >= 0 ? '+' : ''}${skTrend.change4h}%/4ч, EMA ${skTrend.emaDirection === 'UP' ? '↑' : skTrend.emaDirection === 'DOWN' ? '↓' : '→'}.` : ''; ivSkewSignals.push({ id: `iv_skew_${asset.toLowerCase()}_${s.expiry}`, strategy: 'IV Skew', signal: s.signal, underlying: asset, direction: isBullSkew ? 'BULLISH' : 'BEARISH', confidence: sevToConf[s.severity] || 50, severity: s.severity, parameters: { skew25d: s.skew25d, putIv: s.putIv, callIv: s.callIv, putStrike: s.putStrike, callStrike: s.callStrike, expiry: s.expiry, spot, ...(skTrend ? { spotTrend: { bias: skTrend.bias, change1h: skTrend.change1h, change4h: skTrend.change4h } } : {}), }, description: `📊 ${asset} ${spotFmt} — ${skewDir} (skew ${s.skew25d.toFixed(3)}).${skTrendInfo} ${skewAction}.`, rationale: s.description, }); } } // ─── Auto-trade Gamma Plays before merging ───────── try { await autoTradeGammaPlay(gammaSignals, spotPrices); } catch (err) { logger.error(`Auto-trade gamma error: ${err.message}`); } const allSignals = [...strategySignals, ...uvSignals, ...gammaSignals, ...ivSkewSignals] .sort((a, b) => b.confidence - a.confidence); // Push notification for strictly new EXTREME signals const newSignals = allSignals.filter(s => !recentSignalIds.has(s.id) && s.confidence >= 90); if (newSignals.length > 0 && recentSignalIds.size > 0) { const best = newSignals[0]; const { broadcastPushNotification } = require('../routes/notifications'); // Build push notification: title = action, body = context + key numbers const trend = spotTrends[best.underlying]; const spotNow = trend?.spot || best.parameters?.spot; const spotFmt = spotNow >= 100 ? `$${Math.round(spotNow).toLocaleString('en-US')}` : `$${spotNow?.toFixed(2) || '?'}`; const trendBit = trend ? `${trend.change4h >= 0 ? '+' : ''}${trend.change4h}%/4ч` : ''; let pushTitle, pushBody; if (best.trade && (best.strategy === 'Buy Call' || best.strategy === 'Buy Put')) { const dir = best.strategy === 'Buy Call' ? '📈' : '📉'; const strikeFmt = best.trade.strike >= 100 ? `$${Math.round(best.trade.strike).toLocaleString('en-US')}` : `$${best.trade.strike}`; pushTitle = `${dir} ${best.underlying} — ${best.trade.action} ${strikeFmt}`; pushBody = `Спот ${spotFmt} (${trendBit}). DTE ${best.trade.dte}д, TP +${best.trade.target.returnPct}%, SL -${best.trade.stopLoss.lossPct}%`; } else if (best.strategy === 'Gamma Play') { const strikePart = best.parameters?.symbol?.split('-')[2] || ''; const dte = best.parameters.dte <= 1 ? 'СЕГОДНЯ' : `${Math.round(best.parameters.dte)}дн`; pushTitle = `⚡ ${best.underlying} — гамма скальп $${strikePart}`; pushBody = `Экспирация ${dte}, ${best.parameters.distancePercent?.toFixed(1) || '?'}% от страйка. Быстрый вход/выход, theta жжёт!`; } else if (best.strategy === 'Unusual Volume') { const premFmt = best.parameters?.totalWhalePremium >= 1000 ? `$${(best.parameters.totalWhalePremium / 1000).toFixed(0)}K` : `$${best.parameters?.totalWhalePremium?.toFixed(0) || '?'}`; pushTitle = `🐋 ${best.underlying} — кит ${premFmt}`; pushBody = `Спот ${spotFmt} (${trendBit}). V/OI ${best.parameters?.voiRatio?.toFixed(0) || '?'}×. Подтверди спотом перед входом`; } else if (best.strategy.includes('Straddle') || best.strategy.includes('Strangle')) { pushTitle = `🧊 ${best.underlying} — ${best.strategy}`; pushBody = `IV на дне (Rank ${best.parameters?.ivRank || '?'}). Опционы дёшевы — ставка на движение в любую сторону`; } else if (best.strategy === 'Weekend Trap') { pushTitle = `🌙 ${best.underlying} — Weekend Trap`; pushBody = `IV занижена (Rank ${best.parameters?.ivRank || '?'}). Покупка стрэддла — в понедельник IV расширится`; } else { pushTitle = `🔥 ${best.underlying} — ${best.strategy}`; pushBody = best.description?.substring(0, 120) || 'Новый сигнал — открой дашборд'; } await broadcastPushNotification({ title: pushTitle, body: pushBody, icon: '/pwa-192x192.png', data: { url: `/?signalId=${best.id}` } }); } recentSignalIds = new Set(allSignals.map(s => s.id)); await processSignals(allSignals); // ─── Log signals for backtest tracking ─────────── try { await logSignals(allSignals, spotPrices); } catch (err) { logger.error(`Signal logging error: ${err.message}`); } // ─── Track outcomes of past signals ────────────── try { await trackOutcomes(spotPrices); } catch (err) { logger.error(`Outcome tracking error: ${err.message}`); } // ─── Custom Alerts check ───────────────────────── try { await checkCustomAlerts(spotPrices); } catch (err) { logger.error(`Custom alerts check error: ${err.message}`); } } catch (err) { logger.error(`Slow update error: ${err.message}`); } const ms = Date.now() - start; logger.info(`Slow update done (${ms}ms) — signals processed`); } // ─── AUTO-TRADE: Gamma Play ────────────────────────────── // Backtest-validated filters (57 signals, 32W/24L = 57% WR): // conf>=90, DTE<=1d, delta 0.3-0.7, dist<1%, cost $0.50-$3/$5, no counter-trend // Uses existing tradeManager for TP + monitoring // Persistent dedup: check DB for recent trades on same underlying+strike+expiry async function getRecentTradeSymbols() { const symbols = new Set(); try { // In-memory active trades const memTrades = getTrades(); for (const t of memTrades) { if (t.status !== 'CLOSED' && t.status !== 'ERROR') { symbols.add(t.symbol); } } // DB trades from last 24h (covers PM2 restarts) const cutoff = new Date(Date.now() - 24 * 3600 * 1000); const dbTrades = await prisma.tradeLog.findMany({ where: { createdAt: { gte: cutoff }, source: 'auto_gamma' }, select: { symbol: true }, }); for (const t of dbTrades) symbols.add(t.symbol); } catch (err) { logger.error(`[AUTO_GAMMA] Dedup query error: ${err.message}`); } return symbols; } async function autoTradeGammaPlay(gammaSignals, spotPrices) { const cfg = config.trading.autoGamma; if (!cfg.enabled || !config.trading.enabled) return; // Count open positions (Binance + in-memory pending) let openCount = 0; try { const positions = await getPositions(); openCount = (positions || []).filter(p => parseFloat(p.positionAmt) > 0).length; } catch (err) { const active = getTrades().filter(t => t.status !== 'CLOSED' && t.status !== 'ERROR'); openCount = active.length; } if (openCount >= cfg.maxPositions) { logger.info(`[AUTO_GAMMA] Skip — ${openCount}/${cfg.maxPositions} positions open`); return; } // Get already-traded symbols (in-memory + DB last 24h) for dedup const tradedSymbols = await getRecentTradeSymbols(); // Filter signals by backtest-validated criteria const candidates = gammaSignals.filter(s => { if (s.confidence < cfg.minConfidence) return false; const p = s.parameters || {}; const dte = parseFloat(p.dte) || 99; const delta = parseFloat(p.delta) || 0; const cost = parseFloat(p.costUsd) || 0; const distPct = parseFloat(p.distancePercent) || 99; const trend = p.spotTrend || {}; const isCall = s.direction === 'BULLISH'; const isMajor = cfg.majorAssets.includes(s.underlying); const maxCost = isMajor ? cfg.maxCostMajor : cfg.maxCostDefault; // Asset filter if (!cfg.assets.includes(s.underlying)) return false; // DTE: <= 1 day (24h). Above = 20% WR in backtest if (dte > cfg.maxDte) return false; // Delta 0.3-0.7: below = 45% WR, above = 50% WR. Sweet spot 56-66% if (delta < cfg.minDelta || delta > cfg.maxDelta) return false; // Distance from strike: > 1% = 0% WR in backtest if (distPct > cfg.maxDistancePct) return false; // Cost: < $0.50 = 45% WR (dust), > $25 = 0% WR. Per-asset cap. if (cost < cfg.minCostUsd || cost > maxCost) return false; // Counter-trend: 0% WR in backtest if (cfg.noCounterTrend && trend.bias) { const trendUp = trend.bias === 'BULLISH' || trend.bias === 'STRONG_BULLISH'; const trendDown = trend.bias === 'BEARISH' || trend.bias === 'STRONG_BEARISH'; if (isCall && trendDown) return false; if (!isCall && trendUp) return false; } // Dedup: don't re-enter same symbol (underlying+strike+expiry) within 24h const symbol = p.symbol; if (symbol && tradedSymbols.has(symbol)) { logger.info(`[AUTO_GAMMA] ⏭️ Dedup skip: ${symbol} — already traded in last 24h`); return false; } return true; }); if (candidates.length === 0) return; // Sort by confidence desc, take what fits in position limit candidates.sort((a, b) => b.confidence - a.confidence); const slotsAvailable = cfg.maxPositions - openCount; for (let i = 0; i < Math.min(candidates.length, slotsAvailable); i++) { const sig = candidates[i]; const p = sig.parameters || {}; const symbol = p.symbol; const premium = parseFloat(p.costUsd) || 0; const isMajor = cfg.majorAssets.includes(sig.underlying); const maxCost = isMajor ? cfg.maxCostMajor : cfg.maxCostDefault; if (!symbol || premium <= 0) continue; // Clamp entry price to exchange maxPrice (lastPrice can exceed it) const entryPrice = clampPrice(symbol, premium); if (entryPrice <= 0) continue; // TP room check: skip if maxPrice doesn't allow at least 50% gain const maxPrice = getMaxPrice(symbol); if (maxPrice) { const maxGainPct = ((maxPrice / entryPrice) - 1) * 100; if (maxGainPct < 50) { logger.info(`[AUTO_GAMMA] ⏭️ Skip ${symbol}: maxPrice=$${maxPrice} only allows +${maxGainPct.toFixed(0)}% (need ≥50%)`); continue; } } // Calculate quantity: max budget / price, min 0.01 let qty = Math.floor((maxCost / entryPrice) * 100) / 100; if (qty < 0.01) qty = 0.01; // Final cost check const totalCost = qty * entryPrice; if (totalCost > maxCost * 1.1) { qty = Math.floor((maxCost / entryPrice) * 100) / 100; } try { logger.info(`[AUTO_GAMMA] 🎯 Entering: ${symbol} qty=${qty} price=$${entryPrice} cost=$${(qty * entryPrice).toFixed(2)} conf=${sig.confidence} DTE=${p.dte} Δ=${p.delta} dist=${p.distancePercent}%`); const result = await openTradeWithTP({ symbol, quantity: qty, price: entryPrice, tpPct: cfg.tpPct, source: 'auto_gamma', }); tradedSymbols.add(symbol); // prevent duplicates within same scan cycle logger.info(`[AUTO_GAMMA] ✅ Order placed: orderId=${result.orderId} TP=$${result.tpPrice} (+${cfg.tpPct}%)`); openCount++; } catch (err) { const msg = err.response?.data?.msg || err.message; logger.error(`[AUTO_GAMMA] ❌ Failed: ${symbol} — ${msg}`); } } } // ─── LEGACY COMPAT ──────────────────────────────────────── // updateCache + updateAndBroadcast kept for any code that calls them directly async function updateCache() { await fastUpdate(); } async function updateAndBroadcast() { await fastUpdate(); slowUpdate().catch(err => logger.error(`slowUpdate error: ${err.message}`)); } // ─── START ──────────────────────────────────────────────── function start() { const fast = config.cache.fastInterval; const slow = config.cache.slowInterval; logger.info(`Scheduler starting — fast: ${fast / 1000}s, slow: ${slow / 1000}s`); // Load contract units (XRP=100, DOGE=1000) before first data fetch fetchContractUnits().then(units => { cache.contractUnits = units; logger.info(`[SCHEDULER] Contract units cached: ${JSON.stringify(units)}`); }).catch(err => { logger.error(`[SCHEDULER] Contract units fallback: ${err.message}`); cache.contractUnits = { BTC: 1, ETH: 1, SOL: 1, BNB: 1, XRP: 100, DOGE: 1000 }; }); // Initial run: fast + slow together fastUpdate().then(() => { slowUpdate().catch(err => logger.error(`Initial slowUpdate error: ${err.message}`)); }); // Fast cycle — prices always fresh fastTimer = setInterval(fastUpdate, fast); // Slow cycle — heavy analysis slowTimer = setInterval(() => { slowUpdate().catch(err => logger.error(`slowUpdate error: ${err.message}`)); }, slow); } function stop() { if (fastTimer) clearInterval(fastTimer); if (slowTimer) clearInterval(slowTimer); } module.exports = { updateCache, updateAndBroadcast, start, stop, setBroadcast };