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