← Back
const { Router } = require('express');
const { checkApiKey } = require('../middleware/auth');
const { cache, parseSymbol, applyFilters, enrichWithGreeks } = require('../services/cache');

const router = Router();

router.get('/api/options', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ error: 'Data not ready' });
  }
  const filtered = applyFilters(cache.options, req.query);
  const enriched = enrichWithGreeks(filtered);
  res.json({
    lastUpdate: cache.lastUpdate,
    count: enriched.length,
    filters: req.query,
    data: enriched,
  });
});

router.get('/api/summary', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ error: 'Data not ready' });
  }

  const btc = cache.options.filter(o => o.symbol.startsWith('BTC-'));
  const eth = cache.options.filter(o => o.symbol.startsWith('ETH-'));
  const sol = cache.options.filter(o => o.symbol.startsWith('SOL-'));
  const doge = cache.options.filter(o => o.symbol.startsWith('DOGE-'));
  const xrp = cache.options.filter(o => o.symbol.startsWith('XRP-'));
  const bnb = cache.options.filter(o => o.symbol.startsWith('BNB-'));

  const calcStats = (arr) => {
    const calls = arr.filter(o => o.symbol.endsWith('-C'));
    const puts = arr.filter(o => o.symbol.endsWith('-P'));
    const totalVol = arr.reduce((s, o) => s + parseFloat(o.volume || 0), 0);
    return {
      count: arr.length,
      calls: calls.length,
      puts: puts.length,
      totalVolume: totalVol.toFixed(2),
    };
  };

  res.json({
    lastUpdate: cache.lastUpdate,
    BTC: calcStats(btc),
    ETH: calcStats(eth),
    SOL: calcStats(sol),
    DOGE: calcStats(doge),
    XRP: calcStats(xrp),
    BNB: calcStats(bnb),
  });
});

router.get('/api/expiries', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ error: 'Data not ready' });
  }

  const expiries = new Set();
  cache.options.forEach(o => {
    const p = parseSymbol(o.symbol);
    if (p) expiries.add(p.expiry);
  });

  res.json({
    lastUpdate: cache.lastUpdate,
    count: expiries.size,
    expiries: Array.from(expiries).sort(),
  });
});

router.get('/api/top-movers', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ error: 'Data not ready' });
  }

  const limit = Math.min(parseInt(req.query.limit) || 10, 50);
  const underlying = req.query.underlying ? req.query.underlying.toUpperCase() : null;

  let items = cache.options
    .filter(o => o.priceChange != null && o.priceChange !== '')
    .map(o => ({ ...o, _change: parseFloat(o.priceChange) }));

  if (underlying) {
    items = items.filter(o => o.symbol.startsWith(underlying + '-'));
  }

  items.sort((a, b) => b._change - a._change);

  const gainers = items.slice(0, limit).map(({ _change, ...o }) => o);
  const losers = items.slice(-limit).reverse().map(({ _change, ...o }) => o);

  res.json({
    lastUpdate: cache.lastUpdate,
    limit,
    gainers,
    losers,
  });
});

router.get('/api/unusual-volume', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ error: 'Data not ready' });
  }

  const limit = Math.min(parseInt(req.query.limit) || 10, 50);
  const underlying = req.query.underlying ? req.query.underlying.toUpperCase() : null;

  let items = cache.options
    .filter(o => parseFloat(o.volume || 0) > 0)
    .map(o => ({ ...o, _vol: parseFloat(o.volume) }));

  if (underlying) {
    items = items.filter(o => o.symbol.startsWith(underlying + '-'));
  }

  if (items.length === 0) {
    return res.json({
      lastUpdate: cache.lastUpdate,
      avgVolume: 0,
      threshold: 0,
      count: 0,
      data: [],
    });
  }

  const avgVolume = items.reduce((s, o) => s + o._vol, 0) / items.length;
  const threshold = avgVolume * 2;

  const unusual = items
    .filter(o => o._vol >= threshold)
    .sort((a, b) => b._vol - a._vol)
    .slice(0, limit)
    .map(({ _vol, ...o }) => ({
      ...o,
      volumeRatio: parseFloat((_vol / avgVolume).toFixed(2)),
    }));

  res.json({
    lastUpdate: cache.lastUpdate,
    avgVolume: parseFloat(avgVolume.toFixed(4)),
    threshold: parseFloat(threshold.toFixed(4)),
    count: unusual.length,
    data: enrichWithGreeks(unusual),
  });
});

/**
 * GET /api/whale-flow?minPremium=1000&limit=50
 *
 * Live whale flow: largest options trades right now.
 * Sorted by total premium (volume × lastPrice), enriched with Greeks.
 * Shows: contract, direction (buy/sell guess from delta), premium, volume, IV, DTE.
 */
router.get('/api/whale-flow', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ success: false, error: 'Data not ready' });
  }

  const minPremium = parseFloat(req.query.minPremium) || 1000;
  const limit = Math.min(parseInt(req.query.limit) || 50, 100);
  const underlying = req.query.underlying ? req.query.underlying.toUpperCase() : null;

  const flows = [];

  for (const opt of cache.options) {
    const vol = parseFloat(opt.volume || 0);
    if (vol <= 0) continue;

    const lastPrice = parseFloat(opt.lastPrice || 0);
    const premium = lastPrice * vol;
    if (premium < minPremium) continue;

    const parts = opt.symbol.split('-');
    if (parts.length < 4) continue;

    const asset = parts[0];
    if (underlying && asset !== underlying) continue;

    const expiryStr = parts[1];
    const strike = parseFloat(parts[2]);
    const isCall = parts[3] === 'C';

    // DTE
    let dte = null;
    if (expiryStr && expiryStr.length === 6) {
      const year = 2000 + parseInt(expiryStr.substring(0, 2));
      const month = parseInt(expiryStr.substring(2, 4)) - 1;
      const day = parseInt(expiryStr.substring(4, 6));
      const expDate = new Date(Date.UTC(year, month, day, 8, 0, 0));
      dte = Math.max(0, Math.ceil((expDate - Date.now()) / 86400000));
    }

    // Greeks
    const greeks = cache.greeks ? cache.greeks[opt.symbol] : null;
    const delta = greeks ? parseFloat(greeks.delta || 0) : null;
    const iv = greeks ? parseFloat(greeks.markIV || 0) : null;
    const gamma = greeks ? parseFloat(greeks.gamma || 0) : null;

    // Direction guess: based on option type and whether it's ITM/OTM
    // High volume on OTM calls = bullish bet, OTM puts = bearish bet
    let sentiment = 'NEUTRAL';
    if (isCall) sentiment = 'BULLISH';
    else sentiment = 'BEARISH';

    // Size classification
    let size = 'MEDIUM';
    if (premium >= 100000) size = 'WHALE';
    else if (premium >= 50000) size = 'LARGE';
    else if (premium >= 10000) size = 'NOTABLE';

    flows.push({
      symbol: opt.symbol,
      underlying: asset,
      strike,
      expiry: expiryStr,
      dte,
      type: isCall ? 'CALL' : 'PUT',
      sentiment,
      size,
      lastPrice: parseFloat(lastPrice.toFixed(4)),
      volume: vol,
      premium: parseFloat(premium.toFixed(2)),
      openInterest: parseFloat(opt.openInterest || 0),
      voiRatio: parseFloat(opt.openInterest || 0) > 0
        ? parseFloat((vol / parseFloat(opt.openInterest)).toFixed(2))
        : vol > 0 ? 999 : 0,
      delta,
      iv,
      gamma,
      priceChange: parseFloat(opt.priceChange || 0),
    });
  }

  // Sort by premium descending
  flows.sort((a, b) => b.premium - a.premium);

  const totalPremium = flows.reduce((s, f) => s + f.premium, 0);
  const bullishPremium = flows.filter(f => f.sentiment === 'BULLISH').reduce((s, f) => s + f.premium, 0);
  const bearishPremium = flows.filter(f => f.sentiment === 'BEARISH').reduce((s, f) => s + f.premium, 0);

  res.json({
    success: true,
    lastUpdate: cache.lastUpdate,
    count: Math.min(flows.length, limit),
    totalFound: flows.length,
    summary: {
      totalPremium: parseFloat(totalPremium.toFixed(2)),
      bullishPremium: parseFloat(bullishPremium.toFixed(2)),
      bearishPremium: parseFloat(bearishPremium.toFixed(2)),
      bullBearRatio: bearishPremium > 0
        ? parseFloat((bullishPremium / bearishPremium).toFixed(2))
        : bullishPremium > 0 ? 999 : 1,
    },
    data: flows.slice(0, limit),
  });
});

/**
 * GET /api/chain?underlying=BTC&expiry=260424
 *
 * Options chain: one row per strike, calls on left, puts on right.
 * Sorted by strike. Includes Greeks, volume, OI, premium, moneyness.
 * Also returns spot price and max-pain for context.
 */
router.get('/api/chain', checkApiKey, (req, res) => {
  if (!cache.options) {
    return res.status(503).json({ success: false, error: 'Data not ready' });
  }

  const underlying = (req.query.underlying || 'BTC').toUpperCase();
  const expiry = req.query.expiry || null;

  // Get all options for this underlying
  let chain = cache.options.filter(o => o.symbol.startsWith(underlying + '-'));
  if (!chain.length) {
    return res.json({ success: true, underlying, expiry, chain: [], expiries: [] });
  }

  // Available expiries for this underlying
  const expiries = [...new Set(chain.map(o => o.symbol.split('-')[1]))].sort();

  // Pick expiry: use requested, or nearest one
  const targetExpiry = expiry || expiries[0];
  chain = chain.filter(o => o.symbol.includes(`-${targetExpiry}-`));

  // Collect all strikes
  const strikeSet = new Set();
  const callMap = {};
  const putMap = {};

  for (const opt of chain) {
    const parts = opt.symbol.split('-');
    const strike = parseFloat(parts[2]);
    const type = parts[3]; // C or P
    const greeks = cache.greeks ? cache.greeks[opt.symbol] : null;

    strikeSet.add(strike);

    const row = {
      symbol: opt.symbol,
      lastPrice: parseFloat(opt.lastPrice || 0),
      markPrice: greeks ? parseFloat(greeks.markPrice || 0) : parseFloat(opt.lastPrice || 0),
      volume: parseFloat(opt.volume || 0),
      openInterest: parseFloat(opt.openInterest || 0),
      priceChange: parseFloat(opt.priceChange || 0),
      delta: greeks ? parseFloat(greeks.delta || 0) : null,
      gamma: greeks ? parseFloat(greeks.gamma || 0) : null,
      theta: greeks ? parseFloat(greeks.theta || 0) : null,
      vega: greeks ? parseFloat(greeks.vega || 0) : null,
      iv: greeks ? parseFloat(greeks.markIV || 0) : null,
    };

    if (type === 'C') callMap[strike] = row;
    else putMap[strike] = row;
  }

  const strikes = [...strikeSet].sort((a, b) => a - b);

  // Spot price for moneyness
  const { fetchSpotPrices } = require('../services/binance');

  // We'll use a sync approach — spot from cache if available
  // For async spot, frontend can pass it or we approximate
  let spotApprox = 0;
  // Estimate spot from ATM options (strike where call delta ~ 0.5)
  for (const strike of strikes) {
    const c = callMap[strike];
    if (c && c.delta !== null && Math.abs(c.delta - 0.5) < 0.15) {
      spotApprox = strike;
      break;
    }
  }

  // Build chain rows
  const rows = strikes.map(strike => {
    const call = callMap[strike] || null;
    const put = putMap[strike] || null;
    const totalOI = (call?.openInterest || 0) + (put?.openInterest || 0);
    const totalVol = (call?.volume || 0) + (put?.volume || 0);

    let moneyness = 'OTM';
    if (spotApprox > 0) {
      const dist = Math.abs((strike - spotApprox) / spotApprox * 100);
      if (dist < 1) moneyness = 'ATM';
      else if (strike < spotApprox) moneyness = 'ITM_CALL'; // ITM for calls, OTM for puts
      else moneyness = 'ITM_PUT'; // ITM for puts, OTM for calls
    }

    return {
      strike,
      moneyness,
      call,
      put,
      totalOI,
      totalVol,
    };
  });

  // Calculate DTE
  let dte = null;
  if (targetExpiry && targetExpiry.length === 6) {
    const year = 2000 + parseInt(targetExpiry.substring(0, 2));
    const month = parseInt(targetExpiry.substring(2, 4)) - 1;
    const day = parseInt(targetExpiry.substring(4, 6));
    const expDate = new Date(Date.UTC(year, month, day, 8, 0, 0));
    dte = Math.max(0, Math.ceil((expDate - Date.now()) / 86400000));
  }

  res.json({
    success: true,
    lastUpdate: cache.lastUpdate,
    underlying,
    expiry: targetExpiry,
    dte,
    spotApprox,
    strikesCount: strikes.length,
    expiries,
    chain: rows,
  });
});

/**
 * GET /api/iv-surface?underlying=BTC
 *
 * IV Surface data for visualization:
 * 1. smiles[] — IV by strike for each expiry (IV Smile/Skew chart)
 * 2. termStructure[] — ATM IV by expiry (Term Structure chart)
 * 3. surface[] — full 3D data (strike × expiry × IV)
 */
router.get('/api/iv-surface', checkApiKey, (req, res) => {
  if (!cache.options || !cache.greeks) {
    return res.status(503).json({ success: false, error: 'Data not ready' });
  }

  const underlying = (req.query.underlying || 'BTC').toUpperCase();
  const type = (req.query.type || 'CALL').toUpperCase(); // CALL or PUT

  // Filter options for this underlying
  const suffix = type === 'PUT' ? '-P' : '-C';
  const opts = cache.options.filter(o =>
    o.symbol.startsWith(underlying + '-') && o.symbol.endsWith(suffix)
  );

  if (!opts.length) {
    return res.json({ success: true, underlying, type, smiles: [], termStructure: [], surface: [] });
  }

  // Get spot price from cache
  const spot = cache.spotPrices ? cache.spotPrices[underlying] : 0;

  // Collect data: { expiry → [ { strike, iv, delta, volume, oi } ] }
  const byExpiry = {};
  const surface = [];

  for (const opt of opts) {
    const parts = opt.symbol.split('-');
    if (parts.length < 4) continue;

    const expiryStr = parts[1];
    const strike = parseFloat(parts[2]);
    const greeks = cache.greeks[opt.symbol];
    if (!greeks) continue;

    const iv = parseFloat(greeks.markIV || 0);
    if (iv <= 0) continue;

    const delta = parseFloat(greeks.delta || 0);
    const volume = parseFloat(opt.volume || 0);
    const oi = parseFloat(opt.openInterest || 0);

    // DTE
    let dte = null;
    if (expiryStr.length === 6) {
      const year = 2000 + parseInt(expiryStr.substring(0, 2));
      const month = parseInt(expiryStr.substring(2, 4)) - 1;
      const day = parseInt(expiryStr.substring(4, 6));
      const expDate = new Date(Date.UTC(year, month, day, 8, 0, 0));
      dte = Math.max(0, Math.ceil((expDate - Date.now()) / 86400000));
    }

    const point = { strike, iv, delta, volume, oi, expiry: expiryStr, dte };

    if (!byExpiry[expiryStr]) byExpiry[expiryStr] = { expiry: expiryStr, dte, points: [] };
    byExpiry[expiryStr].points.push(point);
    surface.push(point);
  }

  // Build smiles: for each expiry, sort by strike
  const smiles = Object.values(byExpiry)
    .map(e => ({
      expiry: e.expiry,
      dte: e.dte,
      points: e.points.sort((a, b) => a.strike - b.strike),
    }))
    .sort((a, b) => (a.dte || 999) - (b.dte || 999));

  // Build term structure: ATM IV for each expiry
  // ATM = closest to spot, or delta closest to 0.5 for calls / -0.5 for puts
  const termStructure = smiles.map(smile => {
    let atm = null;
    let minDist = Infinity;

    for (const p of smile.points) {
      // Primary: closest to spot
      const dist = spot > 0 ? Math.abs(p.strike - spot) : Math.abs(Math.abs(p.delta) - 0.5);
      if (dist < minDist) {
        minDist = dist;
        atm = p;
      }
    }

    return {
      expiry: smile.expiry,
      dte: smile.dte,
      atmIv: atm ? atm.iv : null,
      atmStrike: atm ? atm.strike : null,
      atmDelta: atm ? atm.delta : null,
      pointCount: smile.points.length,
    };
  }).filter(t => t.atmIv !== null);

  // Compute term structure shape
  let shape = 'FLAT';
  if (termStructure.length >= 2) {
    const first = termStructure[0].atmIv;
    const last = termStructure[termStructure.length - 1].atmIv;
    const diff = (last - first) / first * 100;
    if (diff > 5) shape = 'CONTANGO'; // далёкие дороже = нормально, рынок спокоен
    else if (diff < -5) shape = 'BACKWARDATION'; // ближние дороже = рынок нервничает СЕЙЧАС
  }

  res.json({
    success: true,
    lastUpdate: cache.lastUpdate,
    underlying,
    type,
    spot,
    shape,
    smilesCount: smiles.length,
    smiles,
    termStructure,
    surface,
  });
});

module.exports = router;

📜 Git History

c5b8314feat: add IV Surface tab with smile/skew and term structure charts3 months ago
4515575feat: add Whale Flow tab with live large-trade feed3 months ago
3cdc155feat: add Options Chain View tab with live Greeks3 months ago
163bb5dfeat: migrate to options-screener-v2 folder to isolate deployment4 months ago
Show last diff
Loading...