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