← Back
'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;

📜 Git History

36ce19afix: normalize option premium by contract unit (XRP=100, DOGE=1000)3 months ago
127a136fix: remove SL from all strategies — premium = max loss (isolated margin)3 months ago
9740e5dfeat: combo TP per leg, position sizing from Binance balance, 2-leg TradeModal3 months ago
e2929fcfeat: signal quality overhaul — descriptions, spot analysis, gamma filter, push notifications3 months ago
612398cfeat: signal reliability overhaul — OI data, liquidity gate, realistic PnL tracking3 months ago
b3ccbd6feat: dynamic TP/SL — gamma DTE granularity, whale ITM 200%, time stop, DRY dashboard3 months ago
4889a02feat(signals): add trade recs for Gamma Play and Unusual Volume3 months ago
06783dafix: options-screener-v2 — 9 bug fixes, 5 strategy improvements, 2 infra enhancements3 months ago
660f91efix(api): align dashboard unusual volume payload with updated parameters and tooltip4 months ago
88df106fix(v2): fix breakeven math per coin and deflate unusual volume premium4 months ago
Show last diff
Loading...