← Back
'use strict';

const { getAtmIv } = require('./ivAnalysis');
const { analyzeAllPCRatios } = require('./putCallRatio');
const { analyzeSkew } = require('./ivSkew');
const { getContractUnit } = require('../services/cache');
const { calculateAllMaxPain } = require('./maxPain');
const { findGammaPlays } = require('./gammaPlay');
const { confirmsTrend, trendLabel } = require('./spotTrend');

const ASSETS = ['BTC', 'ETH', 'SOL', 'BNB', 'XRP', 'DOGE'];

function getDte(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));
  return Math.max(0, Math.ceil((expDate - Date.now()) / 86400000));
}

function getIvContext(options, underlying, spotPrice) {
  const atm = getAtmIv(options, underlying, spotPrice);
  if (!atm || atm.iv === 0) return null;
  // Return null ivRank — strategies will skip when ivRank is null.
  // Real IV Rank comes from precomputedIvContexts (DB history).
  return { atmIv: atm.iv, ivRank: null, source: 'insufficient_history', atmSymbol: atm.symbol, atmStrike: atm.strike };
}

// Scans options chain to find the nearest valid contract legs for combo strategies
function findContractLegs(options, underlying, spot, timeframe = 'ALL') {
  const chain = options.filter(o => o.symbol.startsWith(underlying + '-'));
  if (!chain.length) return null;

  let minDte = 2; // Strict default minimum (ALL = avoid same-day)
  let maxDte = 365;
  if (timeframe === '1W') { minDte = 5; maxDte = 14; }
  else if (timeframe === '2W') { minDte = 12; maxDte = 25; }
  else if (timeframe === '1M') { minDte = 25; maxDte = 45; }

  const expiries = [...new Set(chain.map(o => o.symbol.split('-')[1]))]
    .filter(exp => {
      const dte = getDte(exp);
      return dte >= minDte && dte <= maxDte;
    })
    .sort((a, b) => getDte(a) - getDte(b));

  // Fallback to absolute nearest valid expiry if none matched the strict window
  const fallbackExpiries = [...new Set(chain.map(o => o.symbol.split('-')[1]))]
    .filter(exp => getDte(exp) >= 2)
    .sort((a, b) => getDte(a) - getDte(b));

  const safeExpiries = expiries.length > 0 ? expiries : fallbackExpiries;
  if (!safeExpiries.length) return null;
  const expiry = safeExpiries[0]; // Nearest expiry meeting criteria

  const expiryChain = chain.filter(o => o.symbol.includes(`-${expiry}-`));

  // ─── Liquidity gate: reject illiquid options ──────────
  const MIN_OI = 20;           // Minimum open interest
  const MAX_SPREAD_PCT = 0.15; // Max bid-ask spread as % of mid price

  const isLiquid = (o) => {
    const oi = parseFloat(o.openInterest || 0);
    if (oi < MIN_OI) return false;
    const bid = parseFloat(o.bidPrice || 0);
    const ask = parseFloat(o.askPrice || 0);
    if (bid > 0 && ask > 0) {
      const mid = (bid + ask) / 2;
      const spread = (ask - bid) / mid;
      if (spread > MAX_SPREAD_PCT) return false;
    }
    return true;
  };

  // Helper to find specific option by Delta range instead of rigid strike offsets
  const findOptByDelta = (type, minDelta, maxDelta) => {
    let bestOpt = null;
    let minDiff = Infinity;
    const targetMid = (minDelta + maxDelta) / 2;

    for (const o of expiryChain) {
      if (!o.symbol.endsWith(`-${type}`)) continue;
      const delta = o.delta ? parseFloat(o.delta) : null;
      if (delta === null) continue;
      if (!isLiquid(o)) continue; // Skip illiquid

      // Strict filter
      if (delta >= minDelta && delta <= maxDelta) {
        const diff = Math.abs(delta - targetMid);
        if (diff < minDiff) {
          minDiff = diff;
          bestOpt = o;
        }
      }
    }

    // If strict delta fails, try to fallback to ATM closest to 0.5 rules
    if (!bestOpt) {
      let altDiff = Infinity;
      for (const o of expiryChain) {
        if (!o.symbol.endsWith(`-${type}`)) continue;
        const delta = o.delta ? parseFloat(o.delta) : null;
        if (delta === null) continue;
        if (!isLiquid(o)) continue; // Skip illiquid

        let target = type === 'C' ? 0.5 : -0.5;
        const diff = Math.abs(delta - target);
        if (diff < altDiff) {
          altDiff = diff;
          bestOpt = o;
        }
      }
    }

    if (!bestOpt) return null;
    return {
      symbol: bestOpt.symbol,
      strike: parseFloat(bestOpt.strikePrice),
      type,
      price: parseFloat(bestOpt.markPrice || bestOpt.lastPrice || 0),
      delta: parseFloat(bestOpt.delta),
      openInterest: parseFloat(bestOpt.openInterest || 0),
    };
  };

  const callAtm = findOptByDelta('C', 0.40, 0.60);
  const putAtm = findOptByDelta('P', -0.60, -0.40);
  const callOtm = findOptByDelta('C', 0.25, 0.35);
  const putOtm = findOptByDelta('P', -0.35, -0.25);

  return { expiry, callAtm, putAtm, callOtm, putOtm };
}

function buildSignal({ id, strategy, type, signal, underlying, confidence, parameters, rationale, description, tooltip, legs, trade }) {
  return {
    id, strategy, type, signal, underlying,
    confidence: Math.round(Math.min(Math.max(confidence, 0), 99)),
    parameters, rationale,
    description: description || null, // Short human-readable summary
    tooltip, legs,
    trade: trade || null, // Detailed trade recommendation
    timestamp: new Date().toISOString()
  };
}

/**
 * Build a structured trade recommendation for a single-leg option trade
 * @param {Object} opt - the option contract { symbol, strike, type, price, delta }
 * @param {number} spot - current spot price
 * @param {string} expiry - expiry string (e.g. '260207')
 * @param {string} direction - 'LONG_CALL' | 'LONG_PUT'
 * @param {Object} opts - extra options { qty, ivRank }
 */
// ─── Dynamic TP/SL based on delta + DTE ─────────────────
// ITM (delta > 0.5): conservative TP, moves ~1:1 with spot
// ATM (delta ~0.5): moderate leverage
// OTM (delta < 0.3): high leverage, option is cheap, needs bigger TP
// Short DTE: more aggressive TP (gamma explosion possible, but theta kills)

function getDynamicTargets(delta, dte, strategyType) {
  // Base TP% by delta tier
  let tpPct, slPct;

  if (strategyType === 'COMBO') {
    // Straddle/Strangle: premium = max loss (like isolated margin)
    // TP is PER LEG — one leg burns, other must compensate
    // 350% per leg on straddle ≈ +125% on combo (after 1 leg → $0)
    // 500% per leg on strangle ≈ +150% on combo
    if (delta < 0.3) {
      // OTM strangle
      tpPct = 500; slPct = 100; // SL = premium (let it expire)
    } else {
      // ATM straddle
      tpPct = 350; slPct = 100; // SL = premium (let it expire)
    }
  } else if (strategyType === 'WHALE') {
    // Unusual Volume: whale bets — premium = max loss (like isolated margin)
    if (delta < 0.2) {
      tpPct = 500; // Very OTM whale bet — let it ride
    } else if (delta < 0.4) {
      tpPct = 300;
    } else {
      tpPct = 200; // Whale ITM: if whale is right, still 2x
    }
    slPct = 100; // No SL — premium is your max loss
  } else if (strategyType === 'GAMMA') {
    // Gamma Play: near expiry, gamma explosion — DTE granularity
    if (dte !== null && dte <= 1) {
      tpPct = 450; // Same day: max gamma, max TP
    } else if (dte !== null && dte <= 2) {
      tpPct = 350;
    } else {
      tpPct = 300;
    }
    slPct = 100; // No SL — premium is your max loss
  } else {
    // Regular directional (Buy Call / Buy Put) — realistic targets
    if (delta > 0.6) {
      tpPct = 50;    // Deep ITM, moves like spot
    } else if (delta > 0.45) {
      tpPct = 80;    // ATM
    } else if (delta > 0.25) {
      tpPct = 150;   // OTM
    } else {
      tpPct = 250;   // Deep OTM lottery
    }
    slPct = 100; // No SL — buying options: premium = max loss (isolated margin)
  }

  // DTE adjustment: short DTE = more gamma leverage, raise TP
  // (skip for GAMMA — already has DTE-specific values)
  if (strategyType !== 'GAMMA') {
    if (dte !== null && dte <= 3) {
      tpPct = Math.round(tpPct * 1.5); // Near-expiry gamma boost
      slPct = Math.min(slPct + 10, 80); // Wider stop (theta noise)
    } else if (dte !== null && dte <= 7) {
      tpPct = Math.round(tpPct * 1.2);
    }
  }

  // Time stop: for gamma plays, exit if not profitable by 50% of remaining DTE
  const timeStop = strategyType === 'GAMMA' && dte !== null
    ? { exitAfterPctDte: 50, reason: 'Theta decay accelerates — exit if not in profit by half remaining DTE' }
    : null;

  return { tpPct, slPct, timeStop };
}

function buildTradeRec(opt, spot, expiry, direction, opts = {}) {
  if (!opt || !opt.price || opt.price <= 0) return null;

  const isCall = direction === 'LONG_CALL';
  const dte = getDte(expiry);
  const entry = opt.price;          // per-contract price (what you pay)
  const unit = opts.unit || 1;      // contract multiplier (XRP=100, DOGE=1000)
  const premPerUnit = entry / unit;  // premium per 1 unit of underlying
  const strike = opt.strike;
  const delta = Math.abs(opt.delta || 0.5);
  const qty = opts.qty || 1;
  const strategyType = opts.strategyType || 'DIRECTIONAL';

  // Dynamic TP/SL
  const { tpPct, slPct, timeStop } = getDynamicTargets(delta, dte, strategyType);

  // Breakeven: strike + premium-per-unit for calls, strike - premium-per-unit for puts
  const breakeven = isCall ? strike + premPerUnit : strike - premPerUnit;

  // Max loss = premium paid per contract × qty (buying options = limited risk)
  const maxLoss = entry * qty;

  // Target: tpPct% gain on the option premium
  const targetPrice = entry * (1 + tpPct / 100);
  // Spot price at which option reaches target (using delta approximation)
  // delta is per-unit, profit needed is per-contract → divide by (delta × unit)
  const profitNeeded = entry * tpPct / 100;
  const effectiveDelta = delta > 0.05 ? delta : 0.5;
  const rawMoveTarget = profitNeeded / (effectiveDelta * unit);
  const cappedMoveTarget = Math.min(rawMoveTarget, spot * 0.5);
  const spotTarget = isCall ? spot + cappedMoveTarget : spot - cappedMoveTarget;

  // Risk/Reward
  const rrRatio = Math.max(1, Math.round(tpPct / slPct));
  const riskReward = `1:${rrRatio}+`;

  // Time decay warning
  let timeDecayRisk = 'LOW';
  if (dte <= 3) timeDecayRisk = 'CRITICAL';
  else if (dte <= 7) timeDecayRisk = 'HIGH';
  else if (dte <= 14) timeDecayRisk = 'MEDIUM';

  // Position sizing: risk 5% of balance, premium = max loss
  const balance = opts.balance || 0;
  const riskPct = 5;
  const riskAmount = balance > 0 ? balance * riskPct / 100 : 0;
  const suggestedQty = balance > 0 ? Math.max(0.01, Math.floor((riskAmount / entry) * 100) / 100) : qty;
  const riskOfDeposit = balance > 0 ? (entry * suggestedQty / balance * 100) : 0;

  return {
    action: isCall ? 'BUY CALL' : 'BUY PUT',
    contract: opt.symbol,
    symbol: opt.symbol,
    strike,
    expiry,
    dte,
    entry: parseFloat(entry.toFixed(4)),
    breakeven: parseFloat(breakeven.toFixed(2)),
    target: {
      optionPrice: parseFloat(targetPrice.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 * suggestedQty).toFixed(4)),
    qty: suggestedQty,
    riskReward,
    delta: parseFloat(delta.toFixed(3)),
    timeDecayRisk,
    unit,
    premiumPerUnit: parseFloat(premPerUnit.toFixed(6)),
    ...(timeStop ? { timeStop } : {}),
    ...(balance > 0 ? { sizing: { balance: parseFloat(balance.toFixed(2)), riskPct, riskAmount: parseFloat(riskAmount.toFixed(2)), riskOfDeposit: parseFloat(riskOfDeposit.toFixed(1)) } } : {}),
    spot: parseFloat(spot.toFixed(2)),
  };
}

/**
 * Build trade rec for straddle/strangle (2-leg combo)
 */
function buildComboTradeRec(callOpt, putOpt, spot, expiry, opts = {}) {
  if (!callOpt || !putOpt) return null;
  if ((!callOpt.price || callOpt.price <= 0) && (!putOpt.price || putOpt.price <= 0)) return null;

  const dte = getDte(expiry);
  const unit = opts.unit || 1;       // contract multiplier (XRP=100, DOGE=1000)
  const ratioCall = opts.ratioCall || 1;
  const ratioPut = opts.ratioPut || 1;
  const callEntry = callOpt.price || 0;
  const putEntry = putOpt.price || 0;
  const totalEntry = (callEntry * ratioCall) + (putEntry * ratioPut);
  const totalPremPerUnit = totalEntry / unit;

  // Breakeven: strike ± premium-per-unit (not per-contract)
  const breakevenUp = callOpt.strike + totalPremPerUnit;
  const breakevenDown = putOpt.strike - totalPremPerUnit;

  const maxLoss = totalEntry;

  // Dynamic TP/SL for combo
  const avgDelta = (Math.abs(callOpt.delta || 0.5) + Math.abs(putOpt.delta || 0.5)) / 2;
  const { tpPct, slPct, timeStop } = getDynamicTargets(avgDelta, dte, 'COMBO');

  // Spot targets: delta is per-unit, profit is per-contract → divide by (delta × unit)
  const callDelta = Math.abs(callOpt.delta || 0.5);
  const putDelta = Math.abs(putOpt.delta || 0.5);
  const profitNeeded = totalEntry * tpPct / 100;
  const rawTargetUp = profitNeeded / ((callDelta > 0.05 ? callDelta : 0.5) * unit);
  const rawTargetDown = profitNeeded / ((putDelta > 0.05 ? putDelta : 0.5) * unit);
  const spotTargetUp = spot + Math.min(rawTargetUp, spot * 0.5);
  const spotTargetDown = spot - Math.min(rawTargetDown, spot * 0.5);

  const rrRatio = Math.max(1, Math.round(tpPct / slPct));

  let timeDecayRisk = 'LOW';
  if (dte <= 3) timeDecayRisk = 'CRITICAL';
  else if (dte <= 7) timeDecayRisk = 'HIGH';
  else if (dte <= 14) timeDecayRisk = 'MEDIUM';

  // Position sizing: risk 5% of balance, total premium = max loss
  const balance = opts.balance || 0;
  const riskPct = 5;
  const riskAmount = balance > 0 ? balance * riskPct / 100 : 0;
  const suggestedQty = balance > 0 ? Math.max(0.01, Math.floor((riskAmount / totalEntry) * 100) / 100) : 1;
  const riskOfDeposit = balance > 0 ? (totalEntry * suggestedQty / balance * 100) : 0;

  // TP per leg (not per combo) — one leg burns, other compensates
  const callTpPrice = parseFloat((callEntry * (1 + tpPct / 100)).toFixed(4));
  const putTpPrice = parseFloat((putEntry * (1 + tpPct / 100)).toFixed(4));

  return {
    action: ratioCall === ratioPut ? 'BUY STRADDLE' : 'BUY WEIGHTED STRADDLE',
    contracts: [
      { symbol: callOpt.symbol, type: 'CALL', strike: callOpt.strike, qty: suggestedQty * ratioCall, entry: parseFloat(callEntry.toFixed(4)), tpPrice: callTpPrice, tpPct },
      { symbol: putOpt.symbol, type: 'PUT', strike: putOpt.strike, qty: suggestedQty * ratioPut, entry: parseFloat(putEntry.toFixed(4)), tpPrice: putTpPrice, tpPct },
    ],
    expiry,
    dte,
    totalEntry: parseFloat(totalEntry.toFixed(4)),
    breakevenUp: parseFloat(breakevenUp.toFixed(2)),
    breakevenDown: parseFloat(breakevenDown.toFixed(2)),
    target: {
      spotUp: parseFloat(spotTargetUp.toFixed(2)),
      spotDown: parseFloat(spotTargetDown.toFixed(2)),
      returnPct: tpPct,
      note: `TP ${tpPct}% per leg. If 1 leg expires worthless, net profit ≈ ${Math.round(tpPct / 2 - 50)}% on combo.`,
    },
    stopLoss: {
      note: 'Premium = max loss (like isolated margin). Risk managed by qty.',
      lossPct: 100,
    },
    maxLoss: parseFloat((totalEntry * suggestedQty).toFixed(4)),
    riskReward: `1:${rrRatio}+`,
    timeDecayRisk,
    unit,
    ...(timeStop ? { timeStop } : {}),
    ...(balance > 0 ? { sizing: { balance: parseFloat(balance.toFixed(2)), riskPct, riskAmount: parseFloat(riskAmount.toFixed(2)), riskOfDeposit: parseFloat(riskOfDeposit.toFixed(1)) } } : {}),
    spot: parseFloat(spot.toFixed(2)),
  };
}

function analyzeBuyCall(pcrData, skewData, gammaPlays, ivCtx, underlying, spot, legsData, spotTrend) {
  if (!pcrData || !legsData || !legsData.callAtm) return [];
  // Use PCR for the specific expiry of the selected contract, not cross-expiry average
  const expiryPcr = pcrData.expiries?.find(e => e.expiry === legsData.expiry);
  const pcr = expiryPcr?.volumeRatio ?? pcrData.avgVolumeRatio;
  if (!pcr || pcr > 0.7) return [];

  // Phase 8 Rule: Must be IV Rank < 50
  if (!ivCtx || ivCtx.ivRank === null || ivCtx.ivRank > 50) return [];

  // ─── TREND FILTER: reject Buy Call if spot is falling ───
  const trendCheck = confirmsTrend(spotTrend, 'BULLISH');
  if (!trendCheck.confirmed) return []; // Silently skip — spot contradicts bullish signal

  let confidence = 50;
  const rationale = [];

  // Trend bonus: strong bullish trend boosts confidence
  if (spotTrend) {
    const tl = trendLabel(spotTrend);
    if (spotTrend.bias === 'STRONG_BULLISH') { confidence += 10; rationale.push(tl); }
    else if (spotTrend.bias === 'BULLISH') { confidence += 5; rationale.push(tl); }
    else { rationale.push(tl); } // NEUTRAL — show but no bonus
  }

  if (pcr < 0.4) { confidence += 25; rationale.push(`🐋 Киты скупают Calls (PCR ${pcr.toFixed(2)})`); }
  else { confidence += 10; rationale.push(`📊 Повышенный спрос на Calls (PCR ${pcr.toFixed(2)})`); }
  const firstSkew = skewData?.[0];
  if (firstSkew?.skew25d < -0.05) { confidence += 15; rationale.push(`📈 Skew бычий (${firstSkew.skew25d.toFixed(3)})`); }
  // Fix #3: Contradiction penalty — PCR bullish but skew bearish
  if (firstSkew?.skew25d > 0.10) { confidence -= 15; rationale.push(`⚠️ Skew противоречит (${firstSkew.skew25d.toFixed(3)} — медвежий)`); }

  // IV Rank context
  const ivLabel = ivCtx.ivRank < 20 ? 'дешёвая 🔥' : 'умеренная';
  rationale.push(`💎 IV ${ivLabel} (Rank ${ivCtx.ivRank.toFixed(0)}%)`);

  // Filter gammaPlays to same expiry as the selected contract leg
  const legExpiry = legsData.expiry;
  const atmGamma = gammaPlays.find(g => g.underlying === underlying && g.moneyness === 'ATM' && g.expiry === legExpiry);
  if (atmGamma) { confidence += 10; rationale.push(`⚡ Гамма-бомба на $${atmGamma.strike.toLocaleString('en-US')}`); }

  if (confidence < 65) return [];

  const cost = legsData.callAtm.price;

  const trade = buildTradeRec(legsData.callAtm, spot, legsData.expiry, 'LONG_CALL', { ivRank: ivCtx.ivRank, unit: getContractUnit(underlying) });

  // Human-readable description: ЧТО → ПОЧЕМУ → ДЕЙСТВИЕ
  const spotFmt = spot >= 100 ? `$${Math.round(spot).toLocaleString('en-US')}` : `$${spot.toFixed(2)}`;
  const trendPart = spotTrend
    ? `${underlying} ${spotFmt} (${spotTrend.change4h >= 0 ? '+' : ''}${spotTrend.change4h}% за 4ч, ${spotTrend.change24h >= 0 ? '+' : ''}${spotTrend.change24h}% за 24ч, EMA ${spotTrend.emaDirection === 'UP' ? '↑' : spotTrend.emaDirection === 'DOWN' ? '↓' : '→'})`
    : `${underlying} ${spotFmt}`;
  const whyPart = pcr < 0.4
    ? `Крупные игроки активно покупают Calls (PCR ${pcr.toFixed(2)}), опционы дёшевы (IV Rank ${ivCtx.ivRank.toFixed(0)}%)`
    : `Спрос на Calls растёт (PCR ${pcr.toFixed(2)}), опционы дёшевы (IV Rank ${ivCtx.ivRank.toFixed(0)}%)`;
  const strikeFmt = trade?.strike >= 100 ? `$${Math.round(trade.strike).toLocaleString('en-US')}` : `$${trade?.strike}`;
  const actionPart = trade ? `Покупка Call ${strikeFmt}, DTE ${trade.dte}д. TP +${trade.target.returnPct}%, no SL (premium = max loss)` : '';
  const humanDescription = `📈 ${trendPart}. ${whyPart}. ${actionPart}`;

  return [buildSignal({
    id: `buy_call_${underlying.toLowerCase()}`,
    strategy: 'Buy Call', type: 'DIRECTIONAL', signal: 'BUY_CALL', underlying, confidence,
    parameters: {
      pcr: pcr.toFixed(2), costUsd: cost, spot,
      ivRank: `${ivCtx.ivRank.toFixed(1)}%`, delta: legsData.callAtm.delta.toFixed(2),
      ...(spotTrend ? { spotTrend: { bias: spotTrend.bias, change1h: spotTrend.change1h, change4h: spotTrend.change4h, change24h: spotTrend.change24h } } : {}),
    },
    rationale: rationale.join(' • '),
    description: humanDescription,
    tooltip: `Δ ${legsData.callAtm.delta.toFixed(2)} | IV Rank ${ivCtx.ivRank.toFixed(0)}% | Premium $${cost.toFixed(2)}`,
    legs: [`BUY ${legsData.callAtm.symbol} (ATM)`],
    trade,
  })];
}

function analyzeBuyPut(pcrData, skewData, ivCtx, underlying, spot, legsData, spotTrend) {
  if (!pcrData || !legsData || !legsData.putAtm) return [];
  // Use PCR for the specific expiry of the selected contract
  const expiryPcr = pcrData.expiries?.find(e => e.expiry === legsData.expiry);
  const pcr = expiryPcr?.volumeRatio ?? pcrData.avgVolumeRatio;
  if (!pcr || pcr < 1.3) return [];

  // Phase 8 Rule: Must be IV Rank < 50
  if (!ivCtx || ivCtx.ivRank === null || ivCtx.ivRank > 50) return [];

  // ─── TREND FILTER: reject Buy Put if spot is pumping ───
  const trendCheck = confirmsTrend(spotTrend, 'BEARISH');
  if (!trendCheck.confirmed) return [];

  let confidence = 50;
  const rationale = [];

  // Trend bonus: strong bearish trend boosts confidence
  if (spotTrend) {
    const tl = trendLabel(spotTrend);
    if (spotTrend.bias === 'STRONG_BEARISH') { confidence += 10; rationale.push(tl); }
    else if (spotTrend.bias === 'BEARISH') { confidence += 5; rationale.push(tl); }
    else { rationale.push(tl); }
  }

  if (pcr > 1.8) { confidence += 25; rationale.push(`🐋 Киты скупают Puts (PCR ${pcr.toFixed(2)})`); }
  else { confidence += 10; rationale.push(`📊 Повышенный спрос на Puts (PCR ${pcr.toFixed(2)})`); }

  const firstSkew = skewData?.[0];
  if (firstSkew?.skew25d > 0.08) { confidence += 15; rationale.push(`📉 Skew медвежий (${firstSkew.skew25d.toFixed(3)}) — хеджирование`); }
  // Fix #3: Contradiction penalty — PCR bearish but skew bullish
  if (firstSkew?.skew25d < -0.10) { confidence -= 15; rationale.push(`⚠️ Skew противоречит (${firstSkew.skew25d.toFixed(3)} — бычий)`); }

  // IV Rank context
  const ivLabel = ivCtx.ivRank < 20 ? 'дешёвая 🔥' : 'умеренная';
  rationale.push(`💎 IV ${ivLabel} (Rank ${ivCtx.ivRank.toFixed(0)}%)`);

  if (confidence < 65) return [];

  const cost = legsData.putAtm.price;

  const trade = buildTradeRec(legsData.putAtm, spot, legsData.expiry, 'LONG_PUT', { ivRank: ivCtx.ivRank, unit: getContractUnit(underlying) });

  // Human-readable description: ЧТО → ПОЧЕМУ → ДЕЙСТВИЕ
  const spotFmt = spot >= 100 ? `$${Math.round(spot).toLocaleString('en-US')}` : `$${spot.toFixed(2)}`;
  const trendPart = spotTrend
    ? `${underlying} ${spotFmt} (${spotTrend.change4h >= 0 ? '+' : ''}${spotTrend.change4h}% за 4ч, ${spotTrend.change24h >= 0 ? '+' : ''}${spotTrend.change24h}% за 24ч, EMA ${spotTrend.emaDirection === 'UP' ? '↑' : spotTrend.emaDirection === 'DOWN' ? '↓' : '→'})`
    : `${underlying} ${spotFmt}`;
  const whyPart = pcr > 1.8
    ? `Крупные игроки скупают Puts (PCR ${pcr.toFixed(2)}), опционы дёшевы (IV Rank ${ivCtx.ivRank.toFixed(0)}%)`
    : `Спрос на Puts растёт (PCR ${pcr.toFixed(2)}), опционы дёшевы (IV Rank ${ivCtx.ivRank.toFixed(0)}%)`;
  const strikeFmt = trade?.strike >= 100 ? `$${Math.round(trade.strike).toLocaleString('en-US')}` : `$${trade?.strike}`;
  const actionPart = trade ? `Покупка Put ${strikeFmt}, DTE ${trade.dte}д. TP +${trade.target.returnPct}%, no SL (premium = max loss)` : '';
  const humanDescription = `📉 ${trendPart}. ${whyPart}. ${actionPart}`;

  return [buildSignal({
    id: `buy_put_${underlying.toLowerCase()}`,
    strategy: 'Buy Put', type: 'DIRECTIONAL', signal: 'BUY_PUT', underlying, confidence,
    parameters: {
      pcr: pcr.toFixed(2), costUsd: cost, spot,
      ivRank: `${ivCtx.ivRank.toFixed(1)}%`, delta: legsData.putAtm.delta.toFixed(2),
      ...(spotTrend ? { spotTrend: { bias: spotTrend.bias, change1h: spotTrend.change1h, change4h: spotTrend.change4h, change24h: spotTrend.change24h } } : {}),
    },
    rationale: rationale.join(' • '),
    description: humanDescription,
    tooltip: `Δ ${legsData.putAtm.delta.toFixed(2)} | IV Rank ${ivCtx.ivRank.toFixed(0)}% | Premium $${cost.toFixed(2)}`,
    legs: [`BUY ${legsData.putAtm.symbol} (ATM)`],
    trade,
  })];
}

function analyzeBuyStrangle(ivCtx, underlying, spot, legsData) {
  if (!ivCtx || ivCtx.ivRank === null || !legsData || !legsData.callOtm || !legsData.putOtm) return [];

  // Phase 8 Rule: Must be IV Rank < 5 for Strangles
  if (ivCtx.ivRank > 5) return [];

  let confidence = 85 + (5 - ivCtx.ivRank) * 2; // Max 95% confident at 0 IVR
  const cost = legsData.callOtm.price + legsData.putOtm.price;

  const targetCallPrice = legsData.callOtm.price * 3;
  const targetPutPrice = legsData.putOtm.price * 3;

  const trade = buildComboTradeRec(legsData.callOtm, legsData.putOtm, spot, legsData.expiry, { unit: getContractUnit(underlying) });

  return [buildSignal({
    id: `buy_strangle_${underlying.toLowerCase()}`,
    strategy: 'Buy Strangle', type: 'COMBO', signal: 'BUY_STRANGLE', underlying, confidence,
    parameters: { ivRank: `${ivCtx.ivRank.toFixed(1)}%`, costUsd: cost, targetCallPrice, targetPutPrice, spot, callDelta: Math.abs(legsData.callOtm.delta || 0.3), putDelta: Math.abs(legsData.putOtm.delta || -0.3), callPremium: legsData.callOtm.price, putPremium: legsData.putOtm.price },
    description: `🧊 ${underlying} — волатильность на дне (IV Rank ${ivCtx.ivRank.toFixed(1)}%). Опционы максимально дешёвые. Покупка OTM Call + Put = ставка на сильное движение в любую сторону. Стоимость: $${cost.toFixed(4)}, TP +${trade?.target?.returnPct || 400}%`,
    rationale: `🧊 Абсолютное дно волатильности (IV Rank ${ivCtx.ivRank.toFixed(1)}%). Опционы на минимуме — идеально для покупки.`,
    tooltip: `💡 Дешёвая ставка на макро-пробой в любую сторону.\nOTM опционы сейчас на историческом минимуме.\nОбщая стоимость: $${cost.toFixed(4)}.\nTP (+200%): Call $${targetCallPrice.toFixed(4)} / Put $${targetPutPrice.toFixed(4)}.`,
    legs: [
      `BUY 1x ${legsData.callOtm.symbol} (OTM CALL, Delta ${legsData.callOtm.delta?.toFixed(2) || 0})`,
      `BUY 1x ${legsData.putOtm.symbol} (OTM PUT, Delta ${legsData.putOtm.delta?.toFixed(2) || 0})`
    ],
    trade,
  })];
}

function analyzeBuyStraddle(ivCtx, skewData, underlying, spot, legsData) {
  if (!ivCtx || ivCtx.ivRank === null || !legsData || !legsData.callAtm || !legsData.putAtm) return [];

  // Phase 8 Rule: Must be IV Rank < 10 for Straddles
  if (ivCtx.ivRank > 10) return [];

  let confidence = 80 + (10 - ivCtx.ivRank); // Max 90% confident at 0 IVR

  const firstSkew = skewData?.[0] || { skew25d: 0 };
  let ratioCall = 1;
  let ratioPut = 1;
  let rationaleStr = `🧊 Историческое дно IV (Rank ${ivCtx.ivRank.toFixed(0)}%). Опционы максимально дешёвые.`;
  let tooltipTitle = `💡 Классический ATM Straddle (1:1). Зарабатывает на любом сильном движении.`;

  // Edge Feature 2: Weighted Straddle based on extreme Skew panic
  // Raised threshold to ±0.25 and reduced ratio to 2:1 for safer positioning
  if (firstSkew.skew25d > 0.25) {
    // Puts are extremely overpriced compared to Calls (Deep Panic)
    ratioCall = 2;
    ratioPut = 1;
    rationaleStr += ` 😱 Extreme Panic! Путы перегреты (skew ${firstSkew.skew25d.toFixed(3)}).`;
    tooltipTitle = `⚡ WEIGHTED STRADDLE: Рынок в панике → Путы дорогие, Коллы дешёвые.\n2 Колла + 1 Пут. Если паника ложная — профит ×2!`;
    confidence += 5;
  } else if (firstSkew.skew25d < -0.25) {
    // Calls are extremely overpriced (Extreme FOMO)
    ratioCall = 1;
    ratioPut = 2;
    rationaleStr += ` 🚀 Extreme FOMO! Коллы перегреты (skew ${firstSkew.skew25d.toFixed(3)}).`;
    tooltipTitle = `⚡ WEIGHTED STRADDLE: Рынок в FOMO → Коллы дорогие, Путы дешёвые.\n1 Колл + 2 Пута. Если коррекция — профит ×2!`;
    confidence += 5;
  }

  const cost = (legsData.callAtm.price * ratioCall) + (legsData.putAtm.price * ratioPut);

  const trade = buildComboTradeRec(legsData.callAtm, legsData.putAtm, spot, legsData.expiry, { ratioCall, ratioPut, unit: getContractUnit(underlying) });

  const stratName = ratioCall !== ratioPut ? 'Weighted Straddle' : 'Buy Straddle';
  const ratioInfo = ratioCall !== ratioPut ? ` (${ratioCall}C:${ratioPut}P)` : '';
  const straddleDesc = `🧊 ${underlying} — IV на дне (Rank ${ivCtx.ivRank.toFixed(0)}%). Опционы дёшевы. Покупка ATM ${stratName}${ratioInfo} = ставка на любое сильное движение. Стоимость: $${cost.toFixed(4)}, TP +${trade?.target?.returnPct || 250}%`;

  return [buildSignal({
    id: `buy_straddle_${underlying.toLowerCase()}`,
    strategy: stratName,
    type: 'COMBO', signal: 'BUY_STRADDLE', underlying, confidence,
    parameters: { ivRank: `${ivCtx.ivRank.toFixed(1)}%`, costUsd: cost, ratioCall, ratioPut, skew: firstSkew.skew25d.toFixed(3), spot, callDelta: Math.abs(legsData.callAtm.delta || 0.5), putDelta: Math.abs(legsData.putAtm.delta || 0.5), callPremium: legsData.callAtm.price, putPremium: legsData.putAtm.price },
    description: straddleDesc,
    rationale: rationaleStr,
    tooltip: `${tooltipTitle}\nОбщая стоимость комбинации: $${cost.toFixed(4)}.`,
    legs: [
      `BUY ${ratioCall}x ${legsData.callAtm.symbol} (ATM CALL)`,
      `BUY ${ratioPut}x ${legsData.putAtm.symbol} (ATM PUT)`
    ],
    trade,
  })];
}

// Edge Feature 3: Weekend Gamma Trap
function analyzeWeekendTrap(options, ivCtx, underlying, spot) {
  const day = new Date().getUTCDay();
  // Only trigger on Saturday (6) and Sunday (0)
  if (day !== 0 && day !== 6) return [];

  // Phase 8 Rule: Must be IV Rank < 15 for Weekend Trap
  if (!ivCtx || ivCtx.ivRank === null || ivCtx.ivRank > 15) return [];

  const chain = options.filter(o => o.symbol.startsWith(underlying + '-'));
  if (!chain.length) return [];

  // Find options with DTE 3 - 5
  const targetExpiries = [...new Set(chain.map(o => o.symbol.split('-')[1]))]
    .filter(exp => {
      const dte = getDte(exp);
      return dte >= 3 && dte <= 5;
    })
    .sort((a, b) => getDte(a) - getDte(b));

  if (!targetExpiries.length) return [];
  const expiry = targetExpiries[0];
  const expiryChain = chain.filter(o => o.symbol.includes(`-${expiry}-`));

  // Find ATM straddle
  const strikes = [...new Set(expiryChain.map(o => parseFloat(o.strikePrice || o.symbol.split('-')[2])))].sort((a, b) => a - b);
  const atmIdx = strikes.findIndex(s => s >= spot);
  const atmStrike = strikes[atmIdx >= 0 ? atmIdx : strikes.length - 1];

  const callAtm = expiryChain.find(o => parseFloat(o.strikePrice) === atmStrike && o.symbol.endsWith(`-C`));
  const putAtm = expiryChain.find(o => parseFloat(o.strikePrice) === atmStrike && o.symbol.endsWith(`-P`));

  if (!callAtm || !putAtm) return [];

  const callPrice = parseFloat(callAtm.markPrice || callAtm.lastPrice || 0);
  const putPrice = parseFloat(putAtm.markPrice || putAtm.lastPrice || 0);
  const cost = callPrice + putPrice;

  // Dynamic confidence based on IV Rank depth + ATM volume + cost reasonableness
  const atmVolume = parseFloat(callAtm.volume || 0) + parseFloat(putAtm.volume || 0);
  let confidence = 70;
  if (ivCtx.ivRank < 5) confidence += 15;
  else if (ivCtx.ivRank < 10) confidence += 8;
  if (atmVolume > 10) confidence += 5; // Some liquidity at the strike
  if (cost > 0 && cost < spot * 0.02) confidence += 5; // Straddle < 2% of spot = cheap
  confidence = Math.min(confidence, 95);

  const callLeg = { symbol: callAtm.symbol, strike: atmStrike, price: callPrice, delta: parseFloat(callAtm.delta || 0.5) };
  const putLeg = { symbol: putAtm.symbol, strike: atmStrike, price: putPrice, delta: parseFloat(putAtm.delta || -0.5) };
  const trade = buildComboTradeRec(callLeg, putLeg, spot, expiry, { unit: getContractUnit(underlying) });

  return [buildSignal({
    id: `weekend_trap_${underlying.toLowerCase()}`,
    strategy: 'Weekend Trap', type: 'COMBO', signal: 'WEEKEND_TRAP', underlying, confidence,
    parameters: { ivRank: `${ivCtx.ivRank.toFixed(1)}%`, costUsd: cost, dte: getDte(expiry), atmVolume, spot, callDelta: Math.abs(parseFloat(callAtm.delta || 0.5)), putDelta: Math.abs(parseFloat(putAtm.delta || 0.5)), callPremium: callPrice, putPremium: putPrice },
    description: `🌙 ${underlying} — выходной, IV на дне (Rank ${ivCtx.ivRank.toFixed(0)}%). Покупка ATM Straddle $${cost.toFixed(4)}, DTE ${getDte(expiry)}д. В понедельник IV расширяется → прибыль без движения цены.`,
    rationale: `🌙 Выходной → IV занижена (Rank ${ivCtx.ivRank.toFixed(0)}%). DTE ${getDte(expiry)}. Ожидаем расширение в понедельник.`,
    tooltip: `⚡ WEEKEND GAMMA TRAP: Покупка ATM Straddle пока рынок спит.\nВ понедельник IV расширяется → премия растёт автоматически.\nСтоимость: $${cost.toFixed(4)} | DTE ${getDte(expiry)}.`,
    legs: [
      `BUY 1x ${callAtm.symbol} (ATM CALL)`,
      `BUY 1x ${putAtm.symbol} (ATM PUT)`
    ],
    trade,
  })];
}

function analyzeAllStrategies(options, spotPrices, precomputedIvContexts = {}, timeframe = 'ALL', spotTrends = {}) {
  const signals = [];
  const gammaPlays = findGammaPlays(options, spotPrices, { maxDte: 5, maxDistancePercent: 8, limit: 50 });

  for (const underlying of ASSETS) {
    const spot = spotPrices[underlying];
    if (!spot) continue;

    const legsData = findContractLegs(options, underlying, spot, timeframe);
    if (!legsData) continue; // Skip if no valid contract legs matched this timeframe entirely

    const ivCtx = precomputedIvContexts[underlying] || getIvContext(options, underlying, spot);
    const pcrData = analyzeAllPCRatios(options, underlying);
    const skewData = analyzeSkew(options, underlying, spot);
    const spotTrend = spotTrends[underlying] || null;

    signals.push(
      ...analyzeBuyCall(pcrData, skewData, gammaPlays, ivCtx, underlying, spot, legsData, spotTrend),
      ...analyzeBuyPut(pcrData, skewData, ivCtx, underlying, spot, legsData, spotTrend),
      ...analyzeBuyStrangle(ivCtx, underlying, spot, legsData),
      ...analyzeBuyStraddle(ivCtx, skewData, underlying, spot, legsData),
      ...analyzeWeekendTrap(options, ivCtx, underlying, spot)
    );
  }

  return signals.filter(s => s.confidence >= 75).sort((a, b) => b.confidence - a.confidence);
}

module.exports = { analyzeAllStrategies, getDynamicTargets };

📜 Git History

e78fc62feat: auto-trading for Gamma Play + fix fake 100% WR bug10 weeks ago
36ce19afix: normalize option premium by contract unit (XRP=100, DOGE=1000)3 months ago
dbb590arefactor: remove Smart Sizing (qty=2) from Buy Call/Put signals3 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
d7e2b42feat(signals): add detailed trade recommendations with entry/target/stop3 months ago
06783dafix: options-screener-v2 — 9 bug fixes, 5 strategy improvements, 2 infra enhancements3 months ago
Show last diff
Loading...