ORATS integration: implemented client with v1 API, fallback to mock data
- Added ORATS API client with authentication (X-API-Key header) - Implemented market data routes with ORATS → mock fallback - Added ORATS status endpoint (/api/orats/status) - Configured API key in .env (currently returning Forbidden - needs verification) - Market data endpoints: /api/market/:symbol/summary, /gex, /expirations - Screener endpoint uses ORATS when available, falls back to mockmaster
parent
f112a14d60
commit
cd07de09c6
|
|
@ -1,23 +1,18 @@
|
|||
// ORATS connector placeholder.
|
||||
// ORATS Data API v2 client
|
||||
//
|
||||
// When ORATS credentials are supplied via the `ORATS_API_KEY` environment
|
||||
// variable, this client should call the relevant ORATS Data API endpoints
|
||||
// and return responses shaped exactly like the types in `shared/schema.ts`.
|
||||
// Documentation: https://docs.orats.io/
|
||||
// Base URL: https://api.orats.io/datav2
|
||||
// Auth: Bearer token in Authorization header
|
||||
//
|
||||
// Suggested ORATS endpoints for the MVP feature set:
|
||||
// - GET https://api.orats.io/datav2/strikes?ticker=SPY (strike-level gamma + OI)
|
||||
// - GET https://api.orats.io/datav2/summaries?ticker=SPY (IV, IVR, expected move)
|
||||
// - GET https://api.orats.io/datav2/cores?ticker=SPY (spot, daily series)
|
||||
// - GET https://api.orats.io/datav2/monies/implied?ticker=SPY (implied skew curves)
|
||||
// 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
|
||||
//
|
||||
// Required transformations (left as TODO for future wiring):
|
||||
// * Aggregate strike-level gamma * OI * 100 * spot^2 * 0.01 to produce the
|
||||
// bars used by `GexProfile`.
|
||||
// * Derive call wall = strike with max positive call gamma exposure.
|
||||
// * Derive put wall = strike with min (most negative) put gamma exposure.
|
||||
// * HVL/gamma flip = strike where cumulative net gamma crosses zero.
|
||||
//
|
||||
// Until ORATS is wired up the routes call into `marketData.ts` instead.
|
||||
// Rate limits: 100 req/min per API key
|
||||
|
||||
import type { Summary, GexProfile, ExpirationsResponse, SymbolInfo } from "@shared/schema";
|
||||
|
||||
export interface OratsClientConfig {
|
||||
apiKey?: string;
|
||||
|
|
@ -27,28 +22,329 @@ export interface OratsClientConfig {
|
|||
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/datav2";
|
||||
this.baseUrl = config.baseUrl ?? "https://api.orats.io/v1";
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.apiKey.length > 0;
|
||||
}
|
||||
|
||||
// Future wiring: throws until implemented so callers can fall back to mocks.
|
||||
async fetchStrikes(_ticker: string): Promise<never> {
|
||||
throw new Error("OratsClient.fetchStrikes not yet implemented");
|
||||
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));
|
||||
}
|
||||
|
||||
async fetchSummary(_ticker: string): Promise<never> {
|
||||
throw new Error("OratsClient.fetchSummary not yet implemented");
|
||||
const cacheKey = url.toString();
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && cached.expires > Date.now()) {
|
||||
return cached.data as T;
|
||||
}
|
||||
|
||||
async fetchMonies(_ticker: string): Promise<never> {
|
||||
throw new Error("OratsClient.fetchMonies not yet implemented");
|
||||
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();
|
||||
|
|
|
|||
112
server/routes.ts
112
server/routes.ts
|
|
@ -5,13 +5,14 @@ import session from "express-session";
|
|||
import MemoryStore from "memorystore";
|
||||
import {
|
||||
SYMBOLS,
|
||||
computeExpirations,
|
||||
computeGexProfile,
|
||||
computeScreener,
|
||||
computeSummary,
|
||||
computeExpirations as mockExpirations,
|
||||
computeGexProfile as mockGexProfile,
|
||||
computeScreener as mockScreener,
|
||||
computeSummary as mockSummary,
|
||||
} from "./marketData";
|
||||
import { oratsClient } from "./oratsClient";
|
||||
import { registerAuthRoutes } from "./authRoutes";
|
||||
import { initDb } from "./db";
|
||||
|
||||
const SessionMemoryStore = MemoryStore(session);
|
||||
|
||||
|
|
@ -30,10 +31,8 @@ function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown
|
|||
}
|
||||
}
|
||||
|
||||
import { initDb } from "./db";
|
||||
|
||||
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
|
||||
// Initialize database (create tables if not exist)
|
||||
// Initialize database
|
||||
await initDb();
|
||||
|
||||
// Session middleware
|
||||
|
|
@ -43,8 +42,8 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: false, // Set true in production with HTTPS
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -58,24 +57,95 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
|||
app.get("/api/orats/status", (_req, res) => {
|
||||
res.json({
|
||||
configured: oratsClient.isConfigured(),
|
||||
baseUrl: "https://api.orats.io/datav2",
|
||||
baseUrl: oratsClient.baseUrl,
|
||||
apiKey: oratsClient.apiKey.slice(0, 8) + "...",
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/market/:symbol/summary", (req, res) =>
|
||||
withTicker(req, res, (t) => computeSummary(t)),
|
||||
);
|
||||
// Market data routes - use ORATS when configured, fall back to mocks
|
||||
app.get("/api/market/:symbol/summary", async (req, res) => {
|
||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||
if (!KNOWN_TICKERS.has(ticker)) {
|
||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||
}
|
||||
try {
|
||||
const summary = oratsClient.isConfigured()
|
||||
? await oratsClient.computeSummary(ticker)
|
||||
: mockSummary(ticker);
|
||||
res.json(summary);
|
||||
} catch (err) {
|
||||
console.error(`ORATS summary error for ${ticker}:`, err);
|
||||
res.json(mockSummary(ticker)); // Fallback to mock
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/market/:symbol/gex", (req, res) =>
|
||||
withTicker(req, res, (t) => computeGexProfile(t)),
|
||||
);
|
||||
app.get("/api/market/:symbol/gex", async (req, res) => {
|
||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||
if (!KNOWN_TICKERS.has(ticker)) {
|
||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||
}
|
||||
try {
|
||||
const gex = oratsClient.isConfigured()
|
||||
? await oratsClient.computeGexProfile(ticker)
|
||||
: mockGexProfile(ticker);
|
||||
res.json(gex);
|
||||
} catch (err) {
|
||||
console.error(`ORATS GEX error for ${ticker}:`, err);
|
||||
res.json(mockGexProfile(ticker)); // Fallback to mock
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/market/:symbol/expirations", (req, res) =>
|
||||
withTicker(req, res, (t) => computeExpirations(t)),
|
||||
);
|
||||
app.get("/api/market/:symbol/expirations", async (req, res) => {
|
||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||
if (!KNOWN_TICKERS.has(ticker)) {
|
||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||
}
|
||||
try {
|
||||
const exp = oratsClient.isConfigured()
|
||||
? await oratsClient.computeExpirations(ticker)
|
||||
: mockExpirations(ticker);
|
||||
res.json(exp);
|
||||
} catch (err) {
|
||||
console.error(`ORATS expirations error for ${ticker}:`, err);
|
||||
res.json(mockExpirations(ticker)); // Fallback to mock
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/screener", (_req, res) => {
|
||||
res.json(computeScreener());
|
||||
app.get("/api/screener", async (_req, res) => {
|
||||
try {
|
||||
if (oratsClient.isConfigured()) {
|
||||
// Fetch real data for all symbols
|
||||
const summaries = await Promise.all(
|
||||
SYMBOLS.map(s => oratsClient.computeSummary(s.ticker))
|
||||
);
|
||||
const screenerData = summaries.map((summary, i) => {
|
||||
const symbol = SYMBOLS[i];
|
||||
return {
|
||||
ticker: symbol.ticker,
|
||||
name: symbol.name,
|
||||
spot: summary.spot,
|
||||
spotChangePct: summary.spotChangePct,
|
||||
netGex: summary.netGex,
|
||||
gammaRegime: summary.gammaRegime,
|
||||
ivRank: summary.ivRank,
|
||||
ivx: summary.ivx,
|
||||
callWall: summary.callWall,
|
||||
putWall: summary.putWall,
|
||||
distanceToCallWall: ((summary.callWall - summary.spot) / summary.spot) * 100,
|
||||
distanceToPutWall: ((summary.putWall - summary.spot) / summary.spot) * 100,
|
||||
expectedMovePct: summary.expectedMovePct,
|
||||
skew: summary.skew,
|
||||
zeroDteNetGex: summary.netGex, // Simplified for now
|
||||
};
|
||||
});
|
||||
res.json(screenerData);
|
||||
} else {
|
||||
res.json(mockScreener());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("ORATS screener error:", err);
|
||||
res.json(mockScreener()); // Fallback to mock
|
||||
}
|
||||
});
|
||||
|
||||
return httpServer;
|
||||
|
|
|
|||
Loading…
Reference in New Issue