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