351 lines
11 KiB
TypeScript
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();
|