// ORATS Data API v2 client // // Base URL: https://api.orats.io/datav2 // Auth: ?token=API_KEY query parameter // Response format: { "data": [...] } for all endpoints // // Key endpoints: // - GET /strikes?ticker=SPY - Strike-level OI, IV, greeks // - GET /summaries?ticker=SPY - IV curve, skew, implied move // - GET /cores?ticker=SPY - Spot price, forecasts, historical vol // - GET /monies/implied?ticker=SPY - Implied vol skew by delta // // Rate limits: 100 req/min per API key import type { Summary, GexProfile, ExpirationsResponse, TanukiLevels } from "@shared/schema"; export interface OratsClientConfig { apiKey?: string; baseUrl?: string; } export class OratsClient { private apiKey: string; private baseUrl: string; private cache = new Map(); private cacheTtl = 5 * 60 * 1000; // 5 min cache constructor(config: OratsClientConfig = {}) { this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? ""; this.baseUrl = config.baseUrl ?? "https://api.orats.io/datav2"; } isConfigured(): boolean { return this.apiKey.length > 0; } setApiKey(key: string) { this.apiKey = key; this.cache.clear(); } setBaseUrl(url: string) { this.baseUrl = url.replace(/\/+$/, ""); this.cache.clear(); } private async request(endpoint: string, params?: Record): Promise { const url = new URL(`${this.baseUrl}${endpoint}`); if (this.apiKey) { url.searchParams.set("token", this.apiKey); } if (params) { Object.entries(params).forEach(([k, v]) => url.searchParams.append(k, v)); } const cacheKey = url.toString(); const cached = this.cache.get(cacheKey); if (cached && cached.expires > Date.now()) { return cached.data as T; } const response = await fetch(url.toString(), { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(15000), }); if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`ORATS ${response.status}: ${endpoint} ${text.slice(0, 200)}`); } const data = await response.json(); this.cache.set(cacheKey, { data, expires: Date.now() + this.cacheTtl }); return data as T; } // ── Raw fetchers ────────────────────────────────────────────────── async fetchCores(ticker: string): Promise> { return this.request>("/cores", { ticker }); } async fetchSummaries(ticker: string): Promise> { return this.request>("/summaries", { ticker }); } async fetchStrikes(ticker: string): Promise> { return this.request>("/strikes", { ticker }); } async fetchMonies(ticker: string): Promise> { return this.request>("/monies/implied", { ticker }); } // ── Compute GammaDesk Summary ───────────────────────────────────── async computeSummary(ticker: string): Promise { const [coresRes, sumRes, strikesRes] = await Promise.all([ this.fetchCores(ticker), this.fetchSummaries(ticker), this.fetchStrikes(ticker), ]); const core = coresRes.data[0]; const summary = sumRes.data[0]; const strikes = strikesRes.data; // Spot price from cores (pxAtmIv = pin/ATM implied price) or summaries stockPrice const spot = core?.pxAtmIv ?? summary?.stockPrice ?? 0; const priorClose = core?.priorCls ?? spot; const spotChange = spot - priorClose; const spotChangePct = priorClose > 0 ? (spotChange / priorClose) * 100 : 0; // IV from summaries (30d as default "ivx") const ivx = (summary?.iv30d ?? summary?.iv20d ?? core?.atmIvM1 ?? 0) * 100; // ORATS uses decimals // IV Rank: where current IV sits vs 52-week range (0-100 percentile) const ivCurrent = ivx / 100; const ivHigh52w = Math.max(ivCurrent * 1.5, (core?.iv6m ?? 0) / 100 * 1.2); const ivLow52w = Math.min(ivCurrent * 0.7, (core?.iv6m ?? 0) / 100 * 0.8); const ivRange = ivHigh52w - ivLow52w; const ivRank = ivRange > 0 ? Math.min(100, Math.max(0, ((ivCurrent - ivLow52w) / ivRange) * 100)) : 50; // Skew: use ORATS `slope` from cores (change in IV per delta step) // Positive slope = normal skew (puts more expensive) const skew = core?.slope ?? (summary?.skewing ?? 0) * 100 ?? 0; // Expected move from summaries impliedMove (decimal) const expectedMove = (summary?.impliedMove ?? 0) * spot; const expectedMovePct = (summary?.impliedMove ?? 0) * 100; // GEX metrics from strikes const { callWall, putWall, hvl, netGex } = this.computeGexMetrics(strikes, spot); const gammaRegime = netGex >= 0 ? "positive" : "negative"; return { ticker, spot: round2(spot), spotChange: round2(spotChange), spotChangePct: round2(spotChangePct), netGex: Math.round(netGex), gammaRegime, hvl: round2(hvl), callWall: round2(callWall), putWall: round2(putWall), ivRank: round1(ivRank), ivx: round2(ivx), expectedMove: round2(expectedMove), expectedMovePct: round2(expectedMovePct), skew: round2(skew), asOf: new Date().toISOString(), }; } // ── Compute GEX Profile ─────────────────────────────────────────── async computeGexProfile(ticker: string): Promise { const [summary, strikesRes] = await Promise.all([ this.computeSummary(ticker), this.fetchStrikes(ticker), ]); const strikes = strikesRes.data; const spot = summary.spot; // Filter to strikes within ~20% of spot, near-term (DTE<=7), with meaningful OI const strikeFloor = spot * 0.8; const strikeCeiling = spot * 1.2; const relevantStrikes = strikes.filter( (s) => s.dte <= 7 && s.strike >= strikeFloor && s.strike <= strikeCeiling && s.callOpenInterest + s.putOpenInterest > 500 ); // Compute GEX bars using gamma * OI * spot^2 * 0.01 (per 1% move) const bars = relevantStrikes .map((s) => { // Gamma from ORATS is per contract. Call gamma is positive, put gamma is positive too. // Net dealer gamma exposure at this strike: // Call GEX = gamma * callOI * spot^2 * 0.01 (positive - dealer sells calls) // Put GEX = gamma * putOI * spot^2 * 0.01 (negative convention) const gamma = Math.abs(s.gamma || 0); const callGex = gamma * s.callOpenInterest * spot * spot * 0.01; const putGex = gamma * s.putOpenInterest * spot * spot * 0.01; return { strike: round2(s.strike), callGex: Math.round(callGex), putGex: Math.round(-putGex), // Dealer convention: puts negative netGex: Math.round(callGex - putGex), }; }) .filter((b) => b.netGex !== 0) // Skip zero-GEX strikes .sort((a, b) => a.strike - b.strike); return { ticker, spot, hvl: summary.hvl, callWall: summary.callWall, putWall: summary.putWall, bars, asOf: new Date().toISOString(), }; } // ── Compute Expirations Matrix ──────────────────────────────────── async computeExpirations(ticker: string): Promise { const summary = await this.computeSummary(ticker); const strikesRes = await this.fetchStrikes(ticker); const moniesRes = await this.fetchMonies(ticker); const strikes = strikesRes.data; const monies = moniesRes.data; // Group strikes by expiration const expiryMap = new Map(); for (const s of strikes) { const expiry = s.expirDate || ""; if (expiry) { if (!expiryMap.has(expiry)) expiryMap.set(expiry, []); expiryMap.get(expiry)!.push(s); } } // Build rows per expiry const rows = Array.from(expiryMap.entries()).map(([expiry, expiryStrikes]) => { const expiryDate = new Date(expiry); const dte = Math.max(0, Math.floor((expiryDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))); // Find matching money row for this expiry const moneyRow = monies.find((m) => m.expirDate === expiry); const atmIv = ((moneyRow?.atmiv ?? moneyRow?.vol100 ?? 0) * 100) || 0; // ORATS uses decimals // Skew from money row (vol25 - vol75) const vol25 = ((moneyRow?.vol25 ?? 0) * 100) || 0; const vol75 = ((moneyRow?.vol75 ?? 0) * 100) || 0; const skew = vol25 - vol75; // Expected move from ATM IV const expectedMovePct = atmIv > 0 ? atmIv * Math.sqrt(Math.max(dte, 1) / 365) : 0; const expectedMove = (expectedMovePct / 100) * summary.spot; // Net GEX for this expiry let netGex = 0; let callWall = 0; let putWall = 0; let maxCallGex = -Infinity; let maxPutGex = -Infinity; for (const s of expiryStrikes) { const gamma = Math.abs(s.gamma || 0); const callGex = gamma * s.callOpenInterest * summary.spot * summary.spot * 0.01; const putGex = gamma * s.putOpenInterest * summary.spot * summary.spot * 0.01; netGex += callGex - putGex; if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; } if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; } } return { expiry: expiry.slice(0, 10), dte, netGex: Math.round(netGex), ivx: round2(atmIv), skew: round2(skew), expectedMove: round2(expectedMove), expectedMovePct: round2(expectedMovePct), callWall: round2(callWall), putWall: round2(putWall), callSkew: round2(vol75), putSkew: round2(vol25), }; }); rows.sort((a, b) => a.dte - b.dte); return { ticker, spot: summary.spot, rows, asOf: new Date().toISOString(), }; } // ── Tanuki-style levels from ORATS GEX data ─────────────────────── async computeTanukiLevels(ticker: string): Promise { const [summary, strikesRes] = await Promise.all([ this.computeSummary(ticker), this.fetchStrikes(ticker), ]); const spot = summary.spot; const strikes = strikesRes.data; // Filter to near-term strikes with meaningful OI (same as GEX profile) const strikeFloor = spot * 0.75; const strikeCeiling = spot * 1.25; const relevant = strikes .filter( (s) => s.dte <= 7 && s.strike >= strikeFloor && s.strike <= strikeCeiling && s.callOpenInterest + s.putOpenInterest > 500 ) .map((s) => { const gamma = Math.abs(s.gamma || 0); const callGex = gamma * s.callOpenInterest * spot * spot * 0.01; const putGex = gamma * s.putOpenInterest * spot * spot * 0.01; return { strike: s.strike, callGex, putGex, netGex: callGex - putGex, }; }) .sort((a, b) => a.strike - b.strike); // C1 = callWall (strike with highest call GEX overall) const callWallStrike = summary.callWall; // P1 = putWall (strike with highest put GEX overall) const putWallStrike = summary.putWall; // HVL = gamma flip point const hvl = summary.hvl; // cTrans = strike with highest call gamma below callWall const belowCallWall = relevant.filter((b) => b.strike < callWallStrike && b.callGex > 0); const cTrans = belowCallWall.length > 0 ? belowCallWall.reduce((best, b) => (b.callGex > best.callGex ? b : best)).strike : callWallStrike; // pTrans = strike with highest put gamma above putWall const abovePutWall = relevant.filter((b) => b.strike > putWallStrike && b.putGex > 0); const pTrans = abovePutWall.length > 0 ? abovePutWall.reduce((best, b) => (b.putGex > best.putGex ? b : best)).strike : putWallStrike; // Extended levels: C2, C3 from next-highest call GEX strikes above C1 const sortedByCallGex = [...relevant].sort((a, b) => b.callGex - a.callGex); const c2Candidate = sortedByCallGex.find((b) => b.strike > callWallStrike && b.strike !== cTrans); const c3Candidate = sortedByCallGex.find( (b) => b.strike > (c2Candidate?.strike ?? callWallStrike) && b.strike !== c2Candidate?.strike ); // P2, P3 from next-highest put GEX strikes below P1 const sortedByPutGex = [...relevant].sort((a, b) => b.putGex - a.putGex); const p2Candidate = sortedByPutGex.find((b) => b.strike < putWallStrike && b.strike !== pTrans); const p3Candidate = sortedByPutGex.find( (b) => b.strike < (p2Candidate?.strike ?? putWallStrike) && b.strike !== p2Candidate?.strike ); // Find nearest expiry and DTE const expiryDates = [...new Set(strikes.filter((s) => s.expirDate).map((s) => s.expirDate!))]; let nearestExpiry = ""; let nearestDte = 0; for (const d of expiryDates) { const expDate = new Date(d); const dte = Math.max(0, Math.floor((expDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))); if (!nearestExpiry || dte < nearestDte) { nearestExpiry = d.slice(0, 10); nearestDte = dte; } } // Regime classification (same logic as Tanuki client) const regime = this.classifyRegime(spot, callWallStrike, hvl, cTrans, summary.netGex); // Build 5 key levels const levels: TanukiLevels["levels"] = []; const levelDefs = [ { label: "C1", role: "Key Resistance", strike: callWallStrike }, { label: "cTrans", role: "Minor Resistance", strike: cTrans }, { label: "HVL", role: "Gamma Flip", strike: hvl }, { label: "pTrans", role: "Minor Support", strike: pTrans }, { label: "P1", role: "Key Support", strike: putWallStrike }, ]; for (const ld of levelDefs) { if (ld.strike > 0) { levels.push({ role: ld.role, strike: round2(ld.strike), label: ld.label }); } } // Extended levels const extendedLevels: TanukiLevels["extendedLevels"] = [ { label: "C2", strike: c2Candidate ? round2(c2Candidate.strike) : undefined }, { label: "C3", strike: c3Candidate ? round2(c3Candidate.strike) : undefined }, { label: "P2", strike: p2Candidate ? round2(p2Candidate.strike) : undefined }, { label: "P3", strike: p3Candidate ? round2(p3Candidate.strike) : undefined }, ]; // Compute net DEX from strikes (delta * OI * 100 per contract) let netDex = 0; for (const s of strikes.filter((x) => x.dte <= 7)) { netDex += s.delta * s.callOpenInterest * 100; // call delta positive netDex += (s.delta - 1) * s.putOpenInterest * 100; // put delta = s.delta - 1 (negative) } return { symbol: ticker, spotPrice: spot, expiry: nearestExpiry, expiryDte: nearestDte, regime, updatedAt: new Date().toISOString(), levels, extendedLevels, netGex: Math.round(summary.netGex), netDex: Math.round(netDex), }; } // ── Regime Classification (matches Tanuki client logic) ─────────── private classifyRegime(spot: number, c1: number, hvl: number, ctrans: number, netGex: number): TanukiLevels["regime"] { if (spot > c1) return "Positive Extension"; if (spot < hvl) { return netGex > 0 ? "Negative Transition" : "Negative Gamma"; } if (Math.abs(spot - ctrans) / spot < 0.003) return "Transition"; return "Positive Gamma"; } // ── Shared GEX Metrics ──────────────────────────────────────────── private computeGexMetrics(strikes: OratsStrike[], spot: number) { let callWall = spot; let putWall = spot; let maxCallGex = -Infinity; let maxPutGex = -Infinity; let cumulativeNetGex = 0; let hvl = spot; let totalNetGex = 0; // Focus on near-term expirations (DTE <= 7) for gamma pinning signal // Filter to strikes within ~25% of spot with meaningful OI const strikeFloor = spot * 0.8; const strikeCeiling = spot * 1.2; const sorted = strikes .filter((s) => s.dte <= 7 && s.strike >= strikeFloor && s.strike <= strikeCeiling && (s.callOpenInterest + s.putOpenInterest) > 500 ) .sort((a, b) => a.strike - b.strike); for (const s of sorted) { const gamma = Math.abs(s.gamma || 0); const callGex = gamma * s.callOpenInterest * spot * spot * 0.01; const putGex = gamma * s.putOpenInterest * spot * spot * 0.01; const netGex = callGex - putGex; totalNetGex += netGex; cumulativeNetGex += netGex; if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; } if (putGex > maxPutGex) { maxPutGex = putGex; putWall = s.strike; } if (cumulativeNetGex >= 0 && hvl === spot) { hvl = s.strike; } } return { callWall, putWall, hvl, netGex: totalNetGex }; } } // ── ORATS API v2 Types ────────────────────────────────────────────── interface OratsFlatResponse { data: T[]; } export interface OratsCore { ticker: string; tradeDate: string; assetType: number; priorCls: number; pxAtmIv: number; mktCap: number; cVolu: number; cOi: number; pVolu: number; pOi: number; orFcst20d: number; orIvFcst20d: number; iv200Ma: number; atmIvM1: number; atmIvM2: number; atmIvM3: number; atmFcstIvM1: number; atmFcstIvM2: number; [key: string]: unknown; } export interface OratsSummary { ticker: string; tradeDate: string; stockPrice: number; iv10d: number; iv20d: number; iv30d: number; iv60d: number; iv90d: number; iv6m: number; iv1y: number; dlt25Iv30d: number; dlt75Iv30d: number; impliedMove: number; skewing?: number; [key: string]: unknown; } export interface OratsStrike { ticker: string; tradeDate: string; expirDate: string; dte: number; strike: number; stockPrice: number; callVolume: number; callOpenInterest: number; putVolume: number; putOpenInterest: number; callMidIv: number; putMidIv: number; smvVol: number; delta: number; gamma: number; theta: number; vega: number; [key: string]: unknown; } export interface OratsMoney { ticker: string; tradeDate: string; expirDate: string; stockPrice: number; atmiv: number; vol100: number; vol75: number; vol50: number; vol25: number; slope: number; [key: string]: unknown; } function round1(n: number): number { return Math.round(n * 10) / 10; } function round2(n: number): number { return Math.round(n * 100) / 100; } export const oratsClient = new OratsClient();