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