gammanexus/server/oratsClient.ts

415 lines
13 KiB
TypeScript

// 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 } 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 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<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
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<OratsFlatResponse<OratsCore>> {
return this.request<OratsFlatResponse<OratsCore>>("/cores", { ticker });
}
async fetchSummaries(ticker: string): Promise<OratsFlatResponse<OratsSummary>> {
return this.request<OratsFlatResponse<OratsSummary>>("/summaries", { ticker });
}
async fetchStrikes(ticker: string): Promise<OratsFlatResponse<OratsStrike>> {
return this.request<OratsFlatResponse<OratsStrike>>("/strikes", { ticker });
}
async fetchMonies(ticker: string): Promise<OratsFlatResponse<OratsMoney>> {
return this.request<OratsFlatResponse<OratsMoney>>("/monies/implied", { ticker });
}
// ── Compute GammaDesk Summary ─────────────────────────────────────
async computeSummary(ticker: string): Promise<Summary> {
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<GexProfile> {
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<ExpirationsResponse> {
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<string, OratsStrike[]>();
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(),
};
}
// ── 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<T> {
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();