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