ORATS integration: implemented client with v1 API, fallback to mock data

- 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
master
isnowglobal-admin 2026-05-21 21:56:21 -04:00
parent f112a14d60
commit cd07de09c6
2 changed files with 412 additions and 46 deletions

View File

@ -1,23 +1,18 @@
// ORATS connector placeholder. // ORATS Data API v2 client
// //
// When ORATS credentials are supplied via the `ORATS_API_KEY` environment // Documentation: https://docs.orats.io/
// variable, this client should call the relevant ORATS Data API endpoints // Base URL: https://api.orats.io/datav2
// and return responses shaped exactly like the types in `shared/schema.ts`. // Auth: Bearer token in Authorization header
// //
// Suggested ORATS endpoints for the MVP feature set: // Key endpoints:
// - GET https://api.orats.io/datav2/strikes?ticker=SPY (strike-level gamma + OI) // - GET /strikes?ticker=SPY - Strike-level gamma, OI, delta, vega
// - GET https://api.orats.io/datav2/summaries?ticker=SPY (IV, IVR, expected move) // - GET /summaries?ticker=SPY - IV, IVR, expected move, skew
// - GET https://api.orats.io/datav2/cores?ticker=SPY (spot, daily series) // - GET /cores?ticker=SPY - Spot price, daily series
// - GET https://api.orats.io/datav2/monies/implied?ticker=SPY (implied skew curves) // - GET /monies/implied?ticker=SPY - Implied volatility skew curves
// //
// Required transformations (left as TODO for future wiring): // Rate limits: 100 req/min per API key
// * Aggregate strike-level gamma * OI * 100 * spot^2 * 0.01 to produce the
// bars used by `GexProfile`. import type { Summary, GexProfile, ExpirationsResponse, SymbolInfo } from "@shared/schema";
// * 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.
export interface OratsClientConfig { export interface OratsClientConfig {
apiKey?: string; apiKey?: string;
@ -27,28 +22,329 @@ export interface OratsClientConfig {
export class OratsClient { export class OratsClient {
private apiKey: string; private apiKey: string;
private baseUrl: string; private baseUrl: string;
private cache = new Map<string, { data: unknown; expires: number }>();
private cacheTtl = 5 * 60 * 1000; // 5 minute cache
constructor(config: OratsClientConfig = {}) { constructor(config: OratsClientConfig = {}) {
this.apiKey = config.apiKey ?? process.env.ORATS_API_KEY ?? ""; 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 { isConfigured(): boolean {
return this.apiKey.length > 0; return this.apiKey.length > 0;
} }
// Future wiring: throws until implemented so callers can fall back to mocks. private async request<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
async fetchStrikes(_ticker: string): Promise<never> { const url = new URL(`${this.baseUrl}${endpoint}`);
throw new Error("OratsClient.fetchStrikes not yet implemented"); 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<never> { // Fetch strike-level data for gamma exposure calculation
throw new Error("OratsClient.fetchSummary not yet implemented"); async fetchStrikes(ticker: string): Promise<OratsStrikesResponse> {
return this.request<OratsStrikesResponse>("/strikes", { ticker });
} }
async fetchMonies(_ticker: string): Promise<never> { // Fetch summary data (IV, IVR, expected move, skew)
throw new Error("OratsClient.fetchMonies not yet implemented"); async fetchSummaries(ticker: string): Promise<OratsSummariesResponse> {
return this.request<OratsSummariesResponse>("/summaries", { ticker });
}
// Fetch core data (spot price, daily series)
async fetchCores(ticker: string): Promise<OratsCoresResponse> {
return this.request<OratsCoresResponse>("/cores", { ticker });
}
// Fetch implied volatility skew curves
async fetchMonies(ticker: string): Promise<OratsMoniesResponse> {
return this.request<OratsMoniesResponse>("/monies/implied", { ticker });
}
// Fetch available symbols/exchanges
async fetchSymbols(): Promise<OratsSymbolsResponse> {
return this.request<OratsSymbolsResponse>("/symbols");
}
// Compute Summary from ORATS data
async computeSummary(ticker: string): Promise<Summary> {
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<GexProfile> {
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<ExpirationsResponse> {
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<string, any[]>();
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(); export const oratsClient = new OratsClient();

View File

@ -5,13 +5,14 @@ import session from "express-session";
import MemoryStore from "memorystore"; import MemoryStore from "memorystore";
import { import {
SYMBOLS, SYMBOLS,
computeExpirations, computeExpirations as mockExpirations,
computeGexProfile, computeGexProfile as mockGexProfile,
computeScreener, computeScreener as mockScreener,
computeSummary, computeSummary as mockSummary,
} from "./marketData"; } from "./marketData";
import { oratsClient } from "./oratsClient"; import { oratsClient } from "./oratsClient";
import { registerAuthRoutes } from "./authRoutes"; import { registerAuthRoutes } from "./authRoutes";
import { initDb } from "./db";
const SessionMemoryStore = MemoryStore(session); 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<Server> { export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
// Initialize database (create tables if not exist) // Initialize database
await initDb(); await initDb();
// Session middleware // Session middleware
@ -43,8 +42,8 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: false, // Set true in production with HTTPS secure: process.env.NODE_ENV === "production",
maxAge: 24 * 60 * 60 * 1000, // 24 hours 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) => { app.get("/api/orats/status", (_req, res) => {
res.json({ res.json({
configured: oratsClient.isConfigured(), 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) => // Market data routes - use ORATS when configured, fall back to mocks
withTicker(req, res, (t) => computeSummary(t)), 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) => app.get("/api/market/:symbol/gex", async (req, res) => {
withTicker(req, res, (t) => computeGexProfile(t)), 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) => app.get("/api/market/:symbol/expirations", async (req, res) => {
withTicker(req, res, (t) => computeExpirations(t)), 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) => { app.get("/api/screener", async (_req, res) => {
res.json(computeScreener()); 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; return httpServer;