gammanexus/server/oratsClient.ts

552 lines
19 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, TanukiLevels } 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(),
};
}
// ── Tanuki-style levels from ORATS GEX data ───────────────────────
async computeTanukiLevels(ticker: string): Promise<TanukiLevels> {
const [summary, strikesRes] = await Promise.all([
this.computeSummary(ticker),
this.fetchStrikes(ticker),
]);
const spot = summary.spot;
const strikes = strikesRes.data;
// Filter to near-term strikes with meaningful OI (same as GEX profile)
const strikeFloor = spot * 0.75;
const strikeCeiling = spot * 1.25;
const relevant = strikes
.filter(
(s) =>
s.dte <= 7 &&
s.strike >= strikeFloor &&
s.strike <= strikeCeiling &&
s.callOpenInterest + s.putOpenInterest > 500
)
.map((s) => {
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: s.strike,
callGex,
putGex,
netGex: callGex - putGex,
};
})
.sort((a, b) => a.strike - b.strike);
// C1 = callWall (strike with highest call GEX overall)
const callWallStrike = summary.callWall;
// P1 = putWall (strike with highest put GEX overall)
const putWallStrike = summary.putWall;
// HVL = gamma flip point
const hvl = summary.hvl;
// cTrans = strike with highest call gamma below callWall
const belowCallWall = relevant.filter((b) => b.strike < callWallStrike && b.callGex > 0);
const cTrans = belowCallWall.length > 0
? belowCallWall.reduce((best, b) => (b.callGex > best.callGex ? b : best)).strike
: callWallStrike;
// pTrans = strike with highest put gamma above putWall
const abovePutWall = relevant.filter((b) => b.strike > putWallStrike && b.putGex > 0);
const pTrans = abovePutWall.length > 0
? abovePutWall.reduce((best, b) => (b.putGex > best.putGex ? b : best)).strike
: putWallStrike;
// Extended levels: C2, C3 from next-highest call GEX strikes above C1
const sortedByCallGex = [...relevant].sort((a, b) => b.callGex - a.callGex);
const c2Candidate = sortedByCallGex.find((b) => b.strike > callWallStrike && b.strike !== cTrans);
const c3Candidate = sortedByCallGex.find(
(b) => b.strike > (c2Candidate?.strike ?? callWallStrike) && b.strike !== c2Candidate?.strike
);
// P2, P3 from next-highest put GEX strikes below P1
const sortedByPutGex = [...relevant].sort((a, b) => b.putGex - a.putGex);
const p2Candidate = sortedByPutGex.find((b) => b.strike < putWallStrike && b.strike !== pTrans);
const p3Candidate = sortedByPutGex.find(
(b) => b.strike < (p2Candidate?.strike ?? putWallStrike) && b.strike !== p2Candidate?.strike
);
// Find nearest expiry and DTE
const expiryDates = [...new Set(strikes.filter((s) => s.expirDate).map((s) => s.expirDate!))];
let nearestExpiry = "";
let nearestDte = 0;
for (const d of expiryDates) {
const expDate = new Date(d);
const dte = Math.max(0, Math.floor((expDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000)));
if (!nearestExpiry || dte < nearestDte) {
nearestExpiry = d.slice(0, 10);
nearestDte = dte;
}
}
// Regime classification (same logic as Tanuki client)
const regime = this.classifyRegime(spot, callWallStrike, hvl, cTrans, summary.netGex);
// Build 5 key levels
const levels: TanukiLevels["levels"] = [];
const levelDefs = [
{ label: "C1", role: "Key Resistance", strike: callWallStrike },
{ label: "cTrans", role: "Minor Resistance", strike: cTrans },
{ label: "HVL", role: "Gamma Flip", strike: hvl },
{ label: "pTrans", role: "Minor Support", strike: pTrans },
{ label: "P1", role: "Key Support", strike: putWallStrike },
];
for (const ld of levelDefs) {
if (ld.strike > 0) {
levels.push({ role: ld.role, strike: round2(ld.strike), label: ld.label });
}
}
// Extended levels
const extendedLevels: TanukiLevels["extendedLevels"] = [
{ label: "C2", strike: c2Candidate ? round2(c2Candidate.strike) : undefined },
{ label: "C3", strike: c3Candidate ? round2(c3Candidate.strike) : undefined },
{ label: "P2", strike: p2Candidate ? round2(p2Candidate.strike) : undefined },
{ label: "P3", strike: p3Candidate ? round2(p3Candidate.strike) : undefined },
];
// Compute net DEX from strikes (delta * OI * 100 per contract)
let netDex = 0;
for (const s of strikes.filter((x) => x.dte <= 7)) {
netDex += s.delta * s.callOpenInterest * 100; // call delta positive
netDex += (s.delta - 1) * s.putOpenInterest * 100; // put delta = s.delta - 1 (negative)
}
return {
symbol: ticker,
spotPrice: spot,
expiry: nearestExpiry,
expiryDte: nearestDte,
regime,
updatedAt: new Date().toISOString(),
levels,
extendedLevels,
netGex: Math.round(summary.netGex),
netDex: Math.round(netDex),
};
}
// ── Regime Classification (matches Tanuki client logic) ───────────
private classifyRegime(spot: number, c1: number, hvl: number, ctrans: number, netGex: number): TanukiLevels["regime"] {
if (spot > c1) return "Positive Extension";
if (spot < hvl) {
return netGex > 0 ? "Negative Transition" : "Negative Gamma";
}
if (Math.abs(spot - ctrans) / spot < 0.003) return "Transition";
return "Positive Gamma";
}
// ── 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();