From cd07de09c61131d12ddec6102f0d6e538efa7106 Mon Sep 17 00:00:00 2001 From: isnowglobal-admin Date: Thu, 21 May 2026 21:56:21 -0400 Subject: [PATCH] ORATS integration: implemented client with v1 API, fallback to mock data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added ORATS API client with authentication (X-API-Key header) - Implemented market data routes with ORATS → mock fallback - Added ORATS status endpoint (/api/orats/status) - Configured API key in .env (currently returning Forbidden - needs verification) - Market data endpoints: /api/market/:symbol/summary, /gex, /expirations - Screener endpoint uses ORATS when available, falls back to mock --- server/oratsClient.ts | 346 +++++++++++++++++++++++++++++++++++++++--- server/routes.ts | 112 +++++++++++--- 2 files changed, 412 insertions(+), 46 deletions(-) diff --git a/server/oratsClient.ts b/server/oratsClient.ts index ccf8225..e48ddb3 100644 --- a/server/oratsClient.ts +++ b/server/oratsClient.ts @@ -1,23 +1,18 @@ -// ORATS connector placeholder. +// ORATS Data API v2 client // -// When ORATS credentials are supplied via the `ORATS_API_KEY` environment -// variable, this client should call the relevant ORATS Data API endpoints -// and return responses shaped exactly like the types in `shared/schema.ts`. +// Documentation: https://docs.orats.io/ +// Base URL: https://api.orats.io/datav2 +// Auth: Bearer token in Authorization header // -// Suggested ORATS endpoints for the MVP feature set: -// - GET https://api.orats.io/datav2/strikes?ticker=SPY (strike-level gamma + OI) -// - GET https://api.orats.io/datav2/summaries?ticker=SPY (IV, IVR, expected move) -// - GET https://api.orats.io/datav2/cores?ticker=SPY (spot, daily series) -// - GET https://api.orats.io/datav2/monies/implied?ticker=SPY (implied skew curves) +// Key endpoints: +// - GET /strikes?ticker=SPY - Strike-level gamma, OI, delta, vega +// - GET /summaries?ticker=SPY - IV, IVR, expected move, skew +// - GET /cores?ticker=SPY - Spot price, daily series +// - GET /monies/implied?ticker=SPY - Implied volatility skew curves // -// Required transformations (left as TODO for future wiring): -// * Aggregate strike-level gamma * OI * 100 * spot^2 * 0.01 to produce the -// bars used by `GexProfile`. -// * Derive call wall = strike with max positive call gamma exposure. -// * Derive put wall = strike with min (most negative) put gamma exposure. -// * HVL/gamma flip = strike where cumulative net gamma crosses zero. -// -// Until ORATS is wired up the routes call into `marketData.ts` instead. +// Rate limits: 100 req/min per API key + +import type { Summary, GexProfile, ExpirationsResponse, SymbolInfo } from "@shared/schema"; export interface OratsClientConfig { apiKey?: string; @@ -27,28 +22,329 @@ export interface OratsClientConfig { export class OratsClient { private apiKey: string; private baseUrl: string; + private cache = new Map(); + private cacheTtl = 5 * 60 * 1000; // 5 minute cache constructor(config: OratsClientConfig = {}) { this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? ""; - this.baseUrl = config.baseUrl ?? "https://api.orats.io/datav2"; + this.baseUrl = config.baseUrl ?? "https://api.orats.io/v1"; } isConfigured(): boolean { return this.apiKey.length > 0; } - // Future wiring: throws until implemented so callers can fall back to mocks. - async fetchStrikes(_ticker: string): Promise { - throw new Error("OratsClient.fetchStrikes not yet implemented"); + private async request(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}${endpoint}`); + 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: { + "X-API-Key": this.apiKey, + Accept: "application/json", + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + throw new Error(`ORATS API ${response.status}: ${endpoint}`); + } + + const data = await response.json(); + this.cache.set(cacheKey, { data, expires: Date.now() + this.cacheTtl }); + return data as T; } - async fetchSummary(_ticker: string): Promise { - throw new Error("OratsClient.fetchSummary not yet implemented"); + // Fetch strike-level data for gamma exposure calculation + async fetchStrikes(ticker: string): Promise { + return this.request("/strikes", { ticker }); } - async fetchMonies(_ticker: string): Promise { - throw new Error("OratsClient.fetchMonies not yet implemented"); + // Fetch summary data (IV, IVR, expected move, skew) + async fetchSummaries(ticker: string): Promise { + return this.request("/summaries", { ticker }); + } + + // Fetch core data (spot price, daily series) + async fetchCores(ticker: string): Promise { + return this.request("/cores", { ticker }); + } + + // Fetch implied volatility skew curves + async fetchMonies(ticker: string): Promise { + return this.request("/monies/implied", { ticker }); + } + + // Fetch available symbols/exchanges + async fetchSymbols(): Promise { + return this.request("/symbols"); + } + + // Compute Summary from ORATS data + async computeSummary(ticker: string): Promise { + const [cores, summaries] = await Promise.all([ + this.fetchCores(ticker), + this.fetchSummaries(ticker), + ]); + + // Get the latest core data + const latestCore = cores[0]?.data?.[0]; + const spot = latestCore?.price ?? 0; + const spotChange = latestCore?.change ?? 0; + const spotChangePct = ((spotChange / spot) * 100) || 0; + + // Get the latest summary + const latestSummary = summaries[0]?.data?.[0]; + const ivx = latestSummary?.iv ?? 15; + const ivRank = latestSummary?.ivr ?? 50; + const skew = latestSummary?.skew ?? 0; + const expectedMove = latestSummary?.expected_move ?? spot * 0.01; + const expectedMovePct = ((expectedMove / spot) * 100) || 0; + + // Fetch strikes to compute GEX metrics + const strikesData = await this.fetchStrikes(ticker); + const strikes = strikesData[0]?.data ?? []; + + // Compute call wall, put wall, HVL 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 from ORATS strike data + async computeGexProfile(ticker: string): Promise { + const [summary, strikesData] = await Promise.all([ + this.computeSummary(ticker), + this.fetchStrikes(ticker), + ]); + + const strikes = strikesData[0]?.data ?? []; + const spot = summary.spot; + + // Process strikes into GEX bars + const bars = strikes + .filter((s: any) => s.strike && s.strike > 0) + .map((s: any) => { + const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * spot * spot * 0.01; + const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * 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), + }; + }) + .sort((a: any, b: any) => a.strike - b.strike); + + return { + ticker, + spot, + hvl: summary.hvl, + callWall: summary.callWall, + putWall: summary.putWall, + bars, + asOf: new Date().toISOString(), + }; + } + + // Compute expirations response from ORATS data + async computeExpirations(ticker: string): Promise { + const summary = await this.computeSummary(ticker); + const strikesData = await this.fetchStrikes(ticker); + const strikes = strikesData[0]?.data ?? []; + + // Group by expiration and compute metrics for each + const expiryMap = new Map(); + for (const strike of strikes) { + const expiry = strike.expiry || strike.expiration_date; + if (expiry) { + if (!expiryMap.has(expiry)) expiryMap.set(expiry, []); + expiryMap.get(expiry)!.push(strike); + } + } + + 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))); + + // Compute IV, skew, expected move from strikes for this expiry + const ivs = expiryStrikes.map(s => s.iv).filter(v => v > 0); + const avgIv = ivs.length ? ivs.reduce((a, b) => a + b, 0) / ivs.length : 15; + const expectedMovePct = (avgIv / 100) * Math.sqrt(Math.max(dte, 1) / 365) * 100; + const expectedMove = (expectedMovePct / 100) * summary.spot; + + // Compute net GEX for this expiry + let netGex = 0; + for (const s of expiryStrikes) { + const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * summary.spot * summary.spot * 0.01; + const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * summary.spot * summary.spot * 0.01; + netGex += callGex - putGex; + } + + // Compute call/put walls for this expiry + let callWall = 0; + let putWall = 0; + let maxCallGex = -Infinity; + let minPutGex = Infinity; + + for (const s of expiryStrikes) { + const callGex = (s.call_gamma || 0) * (s.call_oi || 0) * 100 * summary.spot * summary.spot * 0.01; + const putGex = (s.put_gamma || 0) * (s.put_oi || 0) * 100 * summary.spot * summary.spot * 0.01; + if (callGex > maxCallGex) { maxCallGex = callGex; callWall = s.strike; } + if (putGex < minPutGex) { minPutGex = putGex; putWall = s.strike; } + } + + return { + expiry: expiry.slice(0, 10), + dte, + netGex: Math.round(netGex), + ivx: round2(avgIv), + skew: round2(summary.skew), + expectedMove: round2(expectedMove), + expectedMovePct: round2(expectedMovePct), + callWall: round2(callWall), + putWall: round2(putWall), + callSkew: round2(avgIv * 0.9), + putSkew: round2(avgIv * 1.1), + }; + }); + + // Sort by DTE + rows.sort((a, b) => a.dte - b.dte); + + return { + ticker, + spot: summary.spot, + rows, + asOf: new Date().toISOString(), + }; + } + + private computeGexMetrics(strikes: any[], spot: number) { + let callWall = spot; + let putWall = spot; + let maxCallGex = -Infinity; + let minPutGex = Infinity; + let cumulativeNetGex = 0; + let hvl = spot; + let totalNetGex = 0; + + // Sort by strike + const sorted = [...strikes].sort((a, b) => a.strike - b.strike); + + for (const strike of sorted) { + const callGex = (strike.call_gamma || 0) * (strike.call_oi || 0) * 100 * spot * spot * 0.01; + const putGex = (strike.put_gamma || 0) * (strike.put_oi || 0) * 100 * spot * spot * 0.01; + const netGex = callGex - putGex; + + totalNetGex += netGex; + cumulativeNetGex += netGex; + + if (callGex > maxCallGex) { maxCallGex = callGex; callWall = strike.strike; } + if (putGex > minPutGex) { minPutGex = putGex; putWall = strike.strike; } + + // HVL is where cumulative net GEX crosses zero + if (cumulativeNetGex >= 0 && hvl === spot) { + hvl = strike.strike; + } + } + + return { callWall, putWall, hvl, netGex: totalNetGex }; } } +// ORATS API response types +export interface OratsStrikesResponse { + [ticker: string]: { + data: Array<{ + strike: number; + expiry: string; + call_oi: number; + put_oi: number; + call_gamma: number; + put_gamma: number; + call_delta: number; + put_delta: number; + call_vega: number; + put_vega: number; + call_iv: number; + put_iv: number; + }>; + }; +} + +export interface OratsSummariesResponse { + [ticker: string]: { + data: Array<{ + iv: number; + ivr: number; + skew: number; + expected_move: number; + }>; + }; +} + +export interface OratsCoresResponse { + [ticker: string]: { + data: Array<{ + price: number; + change: number; + change_pct: number; + volume: number; + }>; + }; +} + +export interface OratsMoniesResponse { + [ticker: string]: { + data: Array<{ + strike: number; + call_iv: number; + put_iv: number; + }>; + }; +} + +export interface OratsSymbolsResponse { + symbols: Array<{ + ticker: string; + name: string; + type: string; + }>; +} + +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(); diff --git a/server/routes.ts b/server/routes.ts index 930f578..eeba40d 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -5,13 +5,14 @@ import session from "express-session"; import MemoryStore from "memorystore"; import { SYMBOLS, - computeExpirations, - computeGexProfile, - computeScreener, - computeSummary, + computeExpirations as mockExpirations, + computeGexProfile as mockGexProfile, + computeScreener as mockScreener, + computeSummary as mockSummary, } from "./marketData"; import { oratsClient } from "./oratsClient"; import { registerAuthRoutes } from "./authRoutes"; +import { initDb } from "./db"; const SessionMemoryStore = MemoryStore(session); @@ -30,10 +31,8 @@ function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown } } -import { initDb } from "./db"; - export async function registerRoutes(httpServer: Server, app: Express): Promise { - // Initialize database (create tables if not exist) + // Initialize database await initDb(); // Session middleware @@ -43,8 +42,8 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< resave: false, saveUninitialized: false, cookie: { - secure: false, // Set true in production with HTTPS - maxAge: 24 * 60 * 60 * 1000, // 24 hours + secure: process.env.NODE_ENV === "production", + maxAge: 24 * 60 * 60 * 1000, }, })); @@ -58,24 +57,95 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< app.get("/api/orats/status", (_req, res) => { res.json({ configured: oratsClient.isConfigured(), - baseUrl: "https://api.orats.io/datav2", + baseUrl: oratsClient.baseUrl, + apiKey: oratsClient.apiKey.slice(0, 8) + "...", }); }); - app.get("/api/market/:symbol/summary", (req, res) => - withTicker(req, res, (t) => computeSummary(t)), - ); + // Market data routes - use ORATS when configured, fall back to mocks + app.get("/api/market/:symbol/summary", async (req, res) => { + const ticker = String(req.params.symbol || "").toUpperCase(); + if (!KNOWN_TICKERS.has(ticker)) { + return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); + } + try { + const summary = oratsClient.isConfigured() + ? await oratsClient.computeSummary(ticker) + : mockSummary(ticker); + res.json(summary); + } catch (err) { + console.error(`ORATS summary error for ${ticker}:`, err); + res.json(mockSummary(ticker)); // Fallback to mock + } + }); - app.get("/api/market/:symbol/gex", (req, res) => - withTicker(req, res, (t) => computeGexProfile(t)), - ); + app.get("/api/market/:symbol/gex", async (req, res) => { + const ticker = String(req.params.symbol || "").toUpperCase(); + if (!KNOWN_TICKERS.has(ticker)) { + return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); + } + try { + const gex = oratsClient.isConfigured() + ? await oratsClient.computeGexProfile(ticker) + : mockGexProfile(ticker); + res.json(gex); + } catch (err) { + console.error(`ORATS GEX error for ${ticker}:`, err); + res.json(mockGexProfile(ticker)); // Fallback to mock + } + }); - app.get("/api/market/:symbol/expirations", (req, res) => - withTicker(req, res, (t) => computeExpirations(t)), - ); + app.get("/api/market/:symbol/expirations", async (req, res) => { + const ticker = String(req.params.symbol || "").toUpperCase(); + if (!KNOWN_TICKERS.has(ticker)) { + return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); + } + try { + const exp = oratsClient.isConfigured() + ? await oratsClient.computeExpirations(ticker) + : mockExpirations(ticker); + res.json(exp); + } catch (err) { + console.error(`ORATS expirations error for ${ticker}:`, err); + res.json(mockExpirations(ticker)); // Fallback to mock + } + }); - app.get("/api/screener", (_req, res) => { - res.json(computeScreener()); + app.get("/api/screener", async (_req, res) => { + try { + if (oratsClient.isConfigured()) { + // Fetch real data for all symbols + const summaries = await Promise.all( + SYMBOLS.map(s => oratsClient.computeSummary(s.ticker)) + ); + const screenerData = summaries.map((summary, i) => { + const symbol = SYMBOLS[i]; + return { + ticker: symbol.ticker, + name: symbol.name, + spot: summary.spot, + spotChangePct: summary.spotChangePct, + netGex: summary.netGex, + gammaRegime: summary.gammaRegime, + ivRank: summary.ivRank, + ivx: summary.ivx, + callWall: summary.callWall, + putWall: summary.putWall, + distanceToCallWall: ((summary.callWall - summary.spot) / summary.spot) * 100, + distanceToPutWall: ((summary.putWall - summary.spot) / summary.spot) * 100, + expectedMovePct: summary.expectedMovePct, + skew: summary.skew, + zeroDteNetGex: summary.netGex, // Simplified for now + }; + }); + res.json(screenerData); + } else { + res.json(mockScreener()); + } + } catch (err) { + console.error("ORATS screener error:", err); + res.json(mockScreener()); // Fallback to mock + } }); return httpServer;