← Назад
'use strict'; const { Router } = require('express'); const { checkApiKey } = require('../middleware/auth'); const { cache, getContractUnit } = require('../services/cache'); const { fetchSpotPrices } = require('../services/binance'); const { analyzeAllStrategies, getDynamicTargets } = require('../analysis/strategies'); const { findUnusualVolume } = require('../analysis/unusualVolume'); const { findGammaPlays } = require('../analysis/gammaPlay'); const { analyzeSkew } = require('../analysis/ivSkew'); const { buildIvContexts } = require('../services/ivHistory'); const { mergeGreeksIntoOptions } = require('../utils/mergeGreeks'); const { fetchSpotTrends } = require('../analysis/spotTrend'); const config = require('../config'); const logger = require('../utils/logger'); const router = Router(); function severityToConfidence(severity) { const map = { EXTREME: 90, HIGH: 75, MEDIUM: 58, LOW: 40 }; return map[severity] || 50; } /** * GET /api/dashboard * * Aggregated first-screen signals combining: * - 4 buy-only strategy signals (strategies.js) * - Unusual volume alerts (VOI ≥ 5x, severity HIGH+) * - Gamma play alerts (strength EXTREME/HIGH) * - IV skew directional signals (BULLISH / STRONG_BEARISH) */ router.get('/api/dashboard', checkApiKey, async (req, res) => { if (!cache.options) { return res.status(503).json({ success: false, error: 'Data not ready' }); } const [spotPrices, spotTrends] = await Promise.all([fetchSpotPrices(), fetchSpotTrends()]); const data = mergeGreeksIntoOptions(cache.options, cache.greeks); const allSignals = []; const timeframe = req.query.timeframe || 'ALL'; // ── 1. Classic Strategy Combo Signals (with real IV contexts from DB) ──── const ivContexts = await buildIvContexts(data, spotPrices); const strategySignals = analyzeAllStrategies(data, spotPrices, ivContexts, timeframe, spotTrends); allSignals.push(...strategySignals); // ── 2. Unusual Volume — only HIGH/EXTREME signals ────────────────────── const uvResult = findUnusualVolume(data, spotPrices, { minVoiRatio: 5, limit: 12 }); for (const item of uvResult.data) { const { severity, direction } = item.signal; if (severity === 'LOW' || severity === 'MEDIUM') continue; const isCall = item.type === 'CALL'; const spot = item.spotPrice; const entry = item.lastPrice; const delta = Math.abs(item.delta || (isCall ? 0.5 : -0.5)); const dte = item.expiry ? Math.max(0, Math.ceil((new Date(Date.UTC(2000 + parseInt(item.expiry.substring(0, 2)), parseInt(item.expiry.substring(2, 4)) - 1, parseInt(item.expiry.substring(4, 6)), 8)) - Date.now()) / 86400000)) : null; // Build trade rec: "follow the whale" let uvTrade = null; if (entry > 0 && spot > 0 && dte !== null) { const uvUnderlying = item.symbol?.split('-')[0] || ''; const uvUnit = getContractUnit(uvUnderlying); const premPerUnit = entry / uvUnit; const breakeven = isCall ? item.strike + premPerUnit : item.strike - premPerUnit; const { tpPct, timeStop: whaleTimeStop } = getDynamicTargets(delta, dte, 'WHALE'); // delta is per-unit, profit is per-contract → divide by (delta × unit) const effectiveDelta = delta > 0.05 ? delta : 0.5; const rawMove = (entry * tpPct / 100) / (effectiveDelta * uvUnit); const cappedMove = Math.min(rawMove, spot * 0.5); const spotTarget = isCall ? spot + cappedMove : spot - cappedMove; let timeDecayRisk = 'LOW'; if (dte <= 3) timeDecayRisk = 'CRITICAL'; else if (dte <= 7) timeDecayRisk = 'HIGH'; else if (dte <= 14) timeDecayRisk = 'MEDIUM'; uvTrade = { action: isCall ? 'BUY CALL (follow whale)' : 'BUY PUT (follow whale)', contract: item.symbol, strike: item.strike, expiry: item.expiry, dte, entry: parseFloat(entry.toFixed(4)), breakeven: parseFloat(breakeven.toFixed(2)), target: { optionPrice: parseFloat((entry * (1 + tpPct / 100)).toFixed(4)), spotPrice: parseFloat(spotTarget.toFixed(2)), returnPct: tpPct, }, stopLoss: { note: 'Premium = max loss (like isolated margin). Risk managed by qty.', lossPct: 100, }, maxLoss: parseFloat(entry.toFixed(4)), qty: 1, riskReward: `1:${Math.max(1, Math.round(tpPct / 100))}+`, delta: parseFloat(delta.toFixed(3)), timeDecayRisk, spot: parseFloat(spot.toFixed(2)), unit: uvUnit, premiumPerUnit: parseFloat(premPerUnit.toFixed(6)), whaleInfo: { totalPremium: item.premiumUsd, volume: item.volume, voiRatio: item.voiRatio, }, }; } const isBull = direction.includes('BULL'); const biasLabel = isBull ? 'рост' : 'падение'; const premFmt = item.premiumUsd >= 1000 ? `$${(item.premiumUsd / 1000).toFixed(0)}K` : `$${item.premiumUsd.toFixed(0)}`; const uvTrend = spotTrends[item.underlying]; const uvTrendInfo = uvTrend ? ` Спот ${uvTrend.change4h >= 0 ? '+' : ''}${uvTrend.change4h}%/4ч, ${uvTrend.change24h >= 0 ? '+' : ''}${uvTrend.change24h}%/24ч, EMA ${uvTrend.emaDirection === 'UP' ? '↑' : uvTrend.emaDirection === 'DOWN' ? '↓' : '→'}.` : ''; const uvDesc = `🐋 ${item.underlying} — кит вложил ${premFmt} в ${item.type} $${item.strike} (объём ${item.voiRatio.toFixed(0)}× от OI).${uvTrendInfo} Крупная ставка на ${biasLabel}. Подтверди спотом перед входом.`; allSignals.push({ id: `unusual_vol_${item.symbol.replace(/-/g, '_').toLowerCase()}`, strategy: 'Unusual Volume', type: 'ANOMALY', signal: direction, underlying: item.underlying, direction: isBull ? 'BULLISH' : 'BEARISH', confidence: severityToConfidence(severity), severity, parameters: { symbol: item.symbol, voiRatio: item.voiRatio, costUsd: item.lastPrice, totalWhalePremium: item.premiumUsd, spot: item.spotPrice }, description: uvDesc, rationale: `🐋 Кит влил ${premFmt} в ${item.type} ${item.underlying}. V/OI ${item.voiRatio.toFixed(1)}×. Ставка на ${biasLabel}.`, tooltip: `🐋 КИТОВЫЙ СЛЕД: ${premFmt} влито в ${item.type} ${item.underlying} $${item.strike}.\nV/OI ${item.voiRatio.toFixed(1)}× — объём в ${item.voiRatio.toFixed(0)} раз превышает OI.\n⚠️ Не копируй слепо — используй как подтверждение!`, trade: uvTrade, timestamp: new Date().toISOString(), }); } // ── 3. Gamma Play — only EXTREME/HIGH, deduped per underlying+expiry+strike ─ const gammaPlaysRaw = findGammaPlays(data, spotPrices, { maxDte: 3, maxDistancePercent: 5, limit: 20 }); const gammaDedup = new Map(); for (const g of gammaPlaysRaw) { if (g.signal.strength !== 'EXTREME' && g.signal.strength !== 'HIGH') continue; const key = `${g.underlying}_${g.expiry}_${g.strike}`; const ex = gammaDedup.get(key); if (!ex || g.gamma > ex.gamma) gammaDedup.set(key, g); } for (const g of [...gammaDedup.values()].slice(0, 5)) { const isCall = g.type === 'CALL'; const spot = g.spotPrice; const entry = g.lastPrice; const delta = Math.abs(g.delta || 0.5); const dte = Math.ceil(g.dte); // Build trade rec for gamma play let gammaTrade = null; if (entry > 0 && spot > 0) { // Gamma plays are special: near-expiry, ATM options with huge gamma // A 1% spot move can cause 10-20% option move → we use premiumMoveFor1Pct const gammaUnit = getContractUnit(g.underlying); const gammaPremPerUnit = entry / gammaUnit; const premMove = g.premiumMoveFor1Pct || 0; const breakeven = isCall ? g.strike + gammaPremPerUnit : g.strike - gammaPremPerUnit; // Target: spot moves 1-2% → option doubles (gamma effect) const spotMoveForDouble = premMove > 0 ? (100 / premMove) : 2; // % spot move needed const spotTarget = isCall ? spot * (1 + spotMoveForDouble / 100) : spot * (1 - spotMoveForDouble / 100); let timeDecayRisk = 'LOW'; if (dte <= 1) timeDecayRisk = 'CRITICAL'; else if (dte <= 2) timeDecayRisk = 'HIGH'; else if (dte <= 3) timeDecayRisk = 'MEDIUM'; // Dynamic TP for gamma plays (centralized in strategies.js) const { tpPct: gammaTpPct, timeStop: gammaTimeStop } = getDynamicTargets(delta, dte, 'GAMMA'); gammaTrade = { action: isCall ? 'BUY CALL (gamma scalp)' : 'BUY PUT (gamma scalp)', contract: g.symbol, strike: g.strike, expiry: g.expiry, dte, entry: parseFloat(entry.toFixed(4)), breakeven: parseFloat(breakeven.toFixed(2)), target: { optionPrice: parseFloat((entry * (1 + gammaTpPct / 100)).toFixed(4)), spotPrice: parseFloat(spotTarget.toFixed(2)), spotMovePct: parseFloat(spotMoveForDouble.toFixed(1)), returnPct: gammaTpPct, }, stopLoss: { note: 'Premium = max loss (like isolated margin). Risk managed by qty.', lossPct: 100, }, maxLoss: parseFloat(entry.toFixed(4)), qty: 1, riskReward: `1:${Math.max(2, Math.round(gammaTpPct / 100))}+`, delta: parseFloat(delta.toFixed(3)), gamma: g.gamma, theta: g.theta, timeDecayRisk, ...(gammaTimeStop ? { timeStop: gammaTimeStop } : {}), unit: gammaUnit, premiumPerUnit: parseFloat(gammaPremPerUnit.toFixed(6)), spot: parseFloat(spot.toFixed(2)), gammaInfo: { premiumMoveFor1PctSpot: premMove, distancePercent: g.distancePercent, moneyness: g.moneyness, }, }; } const expiryLbl = dte <= 1 ? 'СЕГОДНЯ' : `через ${dte} дн.`; const strikeFmt = g.strike >= 100 ? `$${Math.round(g.strike).toLocaleString('en-US')}` : `$${g.strike}`; const moveInfo = g.premiumMoveFor1Pct > 0 ? ` При движении 1% спота премия +${Math.round(g.premiumMoveFor1Pct)}%.` : ''; const gTrend = spotTrends[g.underlying]; const gTrendInfo = gTrend ? ` Спот ${gTrend.change1h >= 0 ? '+' : ''}${gTrend.change1h}%/1ч, EMA ${gTrend.emaDirection === 'UP' ? '↑' : gTrend.emaDirection === 'DOWN' ? '↓' : '→'}.` : ''; // ── Trend filter for Gamma: penalize if spot goes against direction ── let gammaConf = g.signal.strength === 'EXTREME' ? 90 : 75; let trendWarning = ''; if (gTrend) { const trendUp = gTrend.emaDirection === 'UP' || gTrend.change1h > 0.3; const trendDown = gTrend.emaDirection === 'DOWN' || gTrend.change1h < -0.3; if (isCall && trendDown) { gammaConf -= 15; // CALL but spot falling trendWarning = ' ⚠️ Спот падает — CALL рискован!'; } else if (!isCall && trendUp) { gammaConf -= 15; // PUT but spot rising trendWarning = ' ⚠️ Спот растёт — PUT рискован!'; } else if ((isCall && trendUp) || (!isCall && trendDown)) { gammaConf += 5; // Trend confirms direction } } // Skip if confidence dropped below threshold if (gammaConf < 70) continue; // Theta warning with actual value const thetaVal = g.theta ? Math.abs(g.theta) : 0; const thetaWarn = thetaVal > 0 ? `⚠️ Theta -$${thetaVal.toFixed(4)}/день — ${dte <= 1 ? 'сгорит к экспирации!' : 'только быстрый скальп!'}` : `⚠️ Theta сжигает — только быстрый скальп!`; const gammaDesc = `⚡ ${g.underlying} ${g.type} ${strikeFmt} — экспирация ${expiryLbl}, цена ${g.distancePercent.toFixed(1)}% от страйка.${moveInfo}${gTrendInfo}${trendWarning} ${thetaWarn}`; allSignals.push({ id: `gamma_play_${g.symbol.replace(/-/g, '_').toLowerCase()}`, strategy: 'Gamma Play', type: 'ANOMALY', signal: `GAMMA_${g.signal.strength}`, underlying: g.underlying, direction: isCall ? 'BULLISH' : 'BEARISH', confidence: gammaConf, severity: g.signal.strength, parameters: { symbol: g.symbol, dte: g.dte, gamma: g.gamma, theta: g.theta, distancePercent: g.distancePercent }, description: gammaDesc, rationale: `${g.type} ${g.underlying} ${g.strike} (${g.expiry}): gamma ${g.gamma.toFixed(5)}, theta ${(g.theta || 0).toFixed(5)}, ${dte} дн.`, tooltip: g.signal.description, trade: gammaTrade, timestamp: new Date().toISOString(), }); } // ── 4. IV Skew — BULLISH / STRONG_BEARISH only ───────────────────────── 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' ? '↓' : '→'}.` : ''; const skewDesc = `📊 ${asset} ${spotFmt} — ${skewDir} (skew ${s.skew25d.toFixed(3)}).${skTrendInfo} ${skewAction}.`; allSignals.push({ id: `iv_skew_${asset.toLowerCase()}_${s.expiry}`, strategy: 'IV Skew', type: 'ANOMALY', signal: s.signal, underlying: asset, direction: isBullSkew ? 'BULLISH' : 'BEARISH', confidence: severityToConfidence(s.severity), severity: s.severity, parameters: { skew25d: s.skew25d, putIv: s.putIv, callIv: s.callIv, putStrike: s.putStrike, callStrike: s.callStrike, expiry: s.expiry, spot, }, description: skewDesc, rationale: s.description, tooltip: `IV Skew ${asset} (${s.expiry}): ${s.description}\nPut IV: ${s.putIv.toFixed(4)} | Call IV: ${s.callIv.toFixed(4)} | Skew: ${s.skew25d.toFixed(4)}`, timestamp: new Date().toISOString(), }); } } // ── Deduplicate + sort by confidence ─────────────────────────────────── const seen = new Set(); const signals = allSignals .sort((a, b) => b.confidence - a.confidence) .filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; }); // ── Position sizing: enrich signals with suggested qty based on account balance ── let accountBalance = 0; try { if (config.trading?.enabled) { const trading = require('../services/trading'); const acct = await trading.getAccount(); const balArr = acct?.asset || acct?.balance || []; const usdtBal = Array.isArray(balArr) ? balArr.find(b => b.asset === 'USDT') : null; accountBalance = parseFloat(usdtBal?.marginBalance || usdtBal?.availableBalance || 0); logger.info(`[DASHBOARD] Account balance: $${accountBalance.toFixed(2)}`); } } catch (e) { logger.warn(`[DASHBOARD] Balance fetch skipped: ${e.message}`); } if (accountBalance > 0) { const RISK_PCT = 5; const riskAmount = accountBalance * RISK_PCT / 100; for (const sig of signals) { if (!sig.trade) continue; const t = sig.trade; if (t.contracts) { // Combo (straddle/strangle): size by total entry const totalEntry = t.totalEntry || t.contracts.reduce((s, c) => s + (c.entry || 0), 0); if (totalEntry > 0) { const qty = Math.max(0.01, Math.floor((riskAmount / totalEntry) * 100) / 100); t.contracts.forEach(c => { c.qty = qty * (c.qty > 1 ? c.qty : 1); }); t.maxLoss = parseFloat((totalEntry * qty).toFixed(4)); t.sizing = { balance: parseFloat(accountBalance.toFixed(2)), riskPct: RISK_PCT, riskAmount: parseFloat(riskAmount.toFixed(2)), riskOfDeposit: parseFloat((totalEntry * qty / accountBalance * 100).toFixed(1)) }; } } else if (t.entry > 0) { // Single leg const qty = Math.max(0.01, Math.floor((riskAmount / t.entry) * 100) / 100); t.qty = qty; t.maxLoss = parseFloat((t.entry * qty).toFixed(4)); t.sizing = { balance: parseFloat(accountBalance.toFixed(2)), riskPct: RISK_PCT, riskAmount: parseFloat(riskAmount.toFixed(2)), riskOfDeposit: parseFloat((t.entry * qty / accountBalance * 100).toFixed(1)) }; } } } res.json({ success: true, lastUpdate: cache.lastUpdate, spotPrices, count: signals.length, signals, ...(accountBalance > 0 ? { accountBalance: parseFloat(accountBalance.toFixed(2)) } : {}), }); }); module.exports = router;