552 lines
19 KiB
TypeScript
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();
|