← Back
function parseDte(expiryStr) {
  if (!expiryStr || expiryStr.length !== 6) return null;
  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));
  const diffMs = expDate.getTime() - Date.now();
  return Math.max(0,
    parseFloat((diffMs / 86400000).toFixed(2)));
}

function findGammaPlays(optionsData, spotPrices, cfg = {}) {
  const { maxDte = 3, maxDistancePercent = 5,
    limit = 20 } = cfg;
  const results = [];
  for (const opt of optionsData) {
    const parts = opt.symbol.split('-');
    if (parts.length < 4) continue;
    const underlying = parts[0];
    const expiryStr = parts[1];
    const strike = parseFloat(parts[2]);
    const spot = spotPrices[underlying];
    if (!spot) continue;
    const dte = parseDte(expiryStr);
    if (dte === null || dte > maxDte) continue;
    const distPct = Math.abs(
      ((strike - spot) / spot) * 100);
    if (distPct > maxDistancePercent) continue;
    const gamma = parseFloat(opt.gamma || 0);
    const delta = parseFloat(opt.delta || 0);
    const theta = parseFloat(opt.theta || 0);
    const lastPrice = parseFloat(
      opt.lastPrice || opt.close || 0);
    const isCall = opt.side === 'CALL'
      || opt.symbol.includes('-C');
    const premiumMove = lastPrice > 0
      ? ((Math.abs(delta) * spot * 0.01) / lastPrice) * 100
      : 0;
    let strength = 'LOW';
    const isMajor = underlying === 'BTC' || underlying === 'ETH';
    const distLimit1 = isMajor ? 2 : 1;
    const distLimit2 = isMajor ? 3 : 1.5;
    const distLimit3 = isMajor ? 5 : 2.5;

    // Liquidity gate: reject illiquid options (fake gamma spikes)
    const openInterest = parseFloat(opt.openInterest || 0);
    const volume = parseFloat(opt.volume || 0);
    if (openInterest < 20) continue; // Need real OI
    if (volume < 1 && openInterest < 50) continue; // Very thin market
    // Cap premiumMove to prevent garbage from near-zero lastPrice
    if (lastPrice < 0.01 && premiumMove > 500) continue;

    if (dte <= 1 && distPct < distLimit1) strength = 'EXTREME';
    else if (dte <= 2 && distPct < distLimit2) strength = 'HIGH';
    else if (dte <= 3 && distPct < distLimit3) strength = 'MEDIUM';
    const expiryLabel = dte <= 1 ? 'СЕГОДНЯ/ЗАВТРА' : `через ${Math.round(dte)} дн.`;
    const moveLabel = premiumMove > 0 ? ` Движение 1% спота ≈ ${premiumMove.toFixed(0)}% премии!` : '';
    const typeEmoji = isCall ? '📈' : '📉';
    const descs = {
      EXTREME: `⚡ Экспирация ${expiryLabel}! Цена у страйка (${distPct.toFixed(1)}%).${moveLabel}`,
      HIGH: `${typeEmoji} Экспирация ${expiryLabel}. Близко к страйку (${distPct.toFixed(1)}%).${moveLabel}`,
      MEDIUM: `${typeEmoji} Экспирация ${expiryLabel}. Умеренная гамма. Хороший R:R для направленной ставки.`,
      LOW: 'Слабый гамма-эффект.',
    };
    results.push({
      symbol: opt.symbol, underlying,
      expiry: expiryStr, strike,
      type: isCall ? 'CALL' : 'PUT',
      dte, spotPrice: spot,
      distancePercent: parseFloat(distPct.toFixed(2)),
      moneyness: distPct < 1 ? 'ATM'
        : (strike > spot ? 'OTM' : 'ITM'),
      lastPrice, gamma, delta, theta,
      iv: opt.markIV ? parseFloat(opt.markIV) : null,
      volume: parseFloat(opt.volume || 0),
      premiumMoveFor1Pct:
        parseFloat(premiumMove.toFixed(1)),
      signal: {
        strength,
        description: descs[strength]
      },
    });
  }
  results.sort((a, b) => b.gamma - a.gamma);
  return results.slice(0, limit);
}
module.exports = { findGammaPlays, parseDte };

📜 Git History

e841599feat: expired options tracking + exercise history + exit reason fix3 months ago
612398cfeat: signal reliability overhaul — OI data, liquidity gate, realistic PnL tracking3 months ago
06783dafix: options-screener-v2 — 9 bug fixes, 5 strategy improvements, 2 infra enhancements3 months ago
7c09bbefeat(ui): implement deep linking to scroll and highlight specific signal from push notification payload4 months ago
163bb5dfeat: migrate to options-screener-v2 folder to isolate deployment4 months ago
Show last diff
Loading...