gammanexus/server/oratsClient.ts

351 lines
11 KiB
TypeScript

// ORATS Data API v2 client
//
// Documentation: https://docs.orats.io/
// Base URL: https://api.orats.io/datav2
// Auth: Bearer token in Authorization header
//
// 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
//
// Rate limits: 100 req/min per API key
import type { Summary, GexProfile, ExpirationsResponse, SymbolInfo } from "@shared/schema";
export interface OratsClientConfig {
apiKey?: string;
baseUrl?: string;
}
export class OratsClient {
private apiKey: string;
private baseUrl: string;
private cache = new Map<string, { data: unknown; expires: number }>();
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/v1";
}
isConfigured(): boolean {
return this.apiKey.length > 0;
}
private async request<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
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;
}
// Fetch strike-level data for gamma exposure calculation
async fetchStrikes(ticker: string): Promise<OratsStrikesResponse> {
return this.request<OratsStrikesResponse>("/strikes", { ticker });
}
// Fetch summary data (IV, IVR, expected move, skew)
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();