// Deterministic mock ORATS-style data generator for GammaDesk. // // The MVP runs without live credentials. Outputs are stable for a given // (ticker, day) pair so the UI is reproducible across reloads. When ORATS is // wired in via `oratsClient.ts`, replace these generators with live calls and // keep the same response shapes from `shared/schema.ts`. // // Gamma exposure (GEX) primer (estimate used in mock model): // gex_contribution = gamma * open_interest * 100 * spot^2 * 0.01 // where: // - gamma: option gamma (Black-Scholes), per $1 underlying move // - open_interest: contract count outstanding // - 100: standard equity contract multiplier // - spot^2: converts per-share gamma into dollar gamma per 1% move // - 0.01: normalize to "$ change per 1% move in spot" // Sign convention: in this MVP, dealer-positioning long-gamma in calls is // counted positive, short-gamma in puts is counted negative. Toggle in code // by flipping `PUT_SIGN`. import type { ExpirationRow, ExpirationsResponse, GexBar, GexProfile, ScreenerRow, Summary, SymbolInfo, } from "@shared/schema"; const PUT_SIGN = -1; // dealer convention: puts contribute negative gamma // Catalog of supported symbols for the MVP screener / selector. export const SYMBOLS: SymbolInfo[] = [ { ticker: "SPY", name: "SPDR S&P 500 ETF", type: "etf", sector: "Broad index" }, { ticker: "QQQ", name: "Invesco QQQ Trust", type: "etf", sector: "Technology" }, { ticker: "SPX", name: "S&P 500 Index", type: "index", sector: "Broad index" }, { ticker: "AAPL", name: "Apple Inc.", type: "equity", sector: "Technology" }, { ticker: "TSLA", name: "Tesla, Inc.", type: "equity", sector: "Consumer cyclical" }, { ticker: "NVDA", name: "NVIDIA Corporation", type: "equity", sector: "Semiconductors" }, ]; // Reference spot prices and characteristic IV ranges per ticker. These keep // the mock realistic and let regime/skew vary by name. const PROFILES: Record< string, { spot: number; ivBase: number; ivRange: number; skewBias: number; gexBias: number } > = { SPY: { spot: 583.42, ivBase: 12.4, ivRange: 6, skewBias: 0.9, gexBias: 1 }, QQQ: { spot: 502.18, ivBase: 16.1, ivRange: 7, skewBias: 0.6, gexBias: 0.8 }, SPX: { spot: 5827.6, ivBase: 11.9, ivRange: 5, skewBias: 1.1, gexBias: 1.3 }, AAPL: { spot: 226.31, ivBase: 22.7, ivRange: 9, skewBias: 0.3, gexBias: -0.4 }, TSLA: { spot: 248.5, ivBase: 54.2, ivRange: 16, skewBias: -0.2, gexBias: -1.2 }, NVDA: { spot: 138.4, ivBase: 47.9, ivRange: 14, skewBias: 0.1, gexBias: -0.8 }, }; // Deterministic PRNG (mulberry32) seeded by ticker + day-bucket so values are // stable across calls but evolve daily. function dailySeed(ticker: string, salt = 0): number { const day = Math.floor(Date.now() / 86_400_000); let h = 2166136261; const key = `${ticker}|${day}|${salt}`; for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function rng(seed: number): () => number { let s = seed || 1; return () => { s = (s + 0x6d2b79f5) | 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function gauss(rand: () => number): number { // Box-Muller, clipped const u = Math.max(rand(), 1e-6); const v = Math.max(rand(), 1e-6); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } function getProfile(ticker: string) { return PROFILES[ticker] ?? PROFILES.SPY; } function roundTo(value: number, step: number): number { return Math.round(value / step) * step; } function strikeStep(spot: number): number { if (spot >= 1000) return 25; if (spot >= 300) return 5; if (spot >= 100) return 2.5; return 1; } // Gaussian-shaped open interest weight by moneyness, with a slight skew toward // out-of-the-money puts (typical for index hedging flow). function oiWeight(strike: number, spot: number, side: "call" | "put"): number { const m = (strike - spot) / spot; const center = side === "call" ? 0.02 : -0.04; const sigma = side === "call" ? 0.06 : 0.08; const x = (m - center) / sigma; return Math.exp(-0.5 * x * x); } // Approximation of Black-Scholes gamma at strike for a short DTE. We don't // need exact greeks for a mock; we need a believable bell-shaped curve peaked // near ATM. function approxGamma(strike: number, spot: number, dte: number, iv: number): number { const t = Math.max(dte, 1) / 365; const sigma = iv / 100; const denom = spot * sigma * Math.sqrt(t); const m = Math.log(strike / spot) / (sigma * Math.sqrt(t)); const pdf = Math.exp(-0.5 * m * m) / Math.sqrt(2 * Math.PI); return pdf / Math.max(denom, 1e-6); } export function computeSummary(ticker: string): Summary { const profile = getProfile(ticker); const rand = rng(dailySeed(ticker, 1)); const spotJitter = (rand() - 0.5) * 0.01 * profile.spot; const spot = profile.spot + spotJitter; const spotChange = (rand() - 0.5) * profile.spot * 0.015; const spotChangePct = (spotChange / spot) * 100; // Net GEX in $ billions. Scale up for indices, allow negative regimes for // single names. const gexMagnitude = (profile.gexBias + (rand() - 0.5) * 0.6) * 1.4e9; const netGex = gexMagnitude; const gammaRegime = netGex >= 0 ? "positive" : "negative"; const step = strikeStep(spot); const hvl = roundTo(spot * (1 + (rand() - 0.5) * 0.01), step); const callWall = roundTo(spot * (1 + 0.012 + rand() * 0.025), step); const putWall = roundTo(spot * (1 - 0.018 - rand() * 0.03), step); const ivx = profile.ivBase + (rand() - 0.5) * profile.ivRange; const ivRank = Math.max(2, Math.min(98, 30 + (rand() - 0.5) * 90)); const expectedMovePct = (ivx / 100) * Math.sqrt(1 / 252) * 100; const expectedMove = (expectedMovePct / 100) * spot; const skew = profile.skewBias + (rand() - 0.5) * 1.2; return { ticker, spot: round2(spot), spotChange: round2(spotChange), spotChangePct: round2(spotChangePct), netGex: Math.round(netGex), gammaRegime, hvl, callWall, putWall, ivRank: round1(ivRank), ivx: round2(ivx), expectedMove: round2(expectedMove), expectedMovePct: round2(expectedMovePct), skew: round2(skew), asOf: new Date().toISOString(), }; } export function computeGexProfile(ticker: string): GexProfile { const profile = getProfile(ticker); const summary = computeSummary(ticker); const rand = rng(dailySeed(ticker, 2)); const spot = summary.spot; const step = strikeStep(spot); // Range: ~+/- 12% around spot const minStrike = roundTo(spot * 0.88, step); const maxStrike = roundTo(spot * 1.12, step); const bars: GexBar[] = []; const dte = 14; const iv = summary.ivx; for (let k = minStrike; k <= maxStrike + 1e-6; k += step) { const strike = round2(k); const gamma = approxGamma(strike, spot, dte, iv); const callRegimeBoost = summary.gammaRegime === "positive" ? 1.55 : 0.75; const putRegimeBoost = summary.gammaRegime === "positive" ? 0.65 : 1.55; const callOi = 20_000 * callRegimeBoost * oiWeight(strike, spot, "call") * (0.85 + rand() * 0.35); const putOi = 20_000 * putRegimeBoost * oiWeight(strike, spot, "put") * (0.85 + rand() * 0.35); // gex = gamma * OI * 100 * spot^2 * 0.01 const callGex = gamma * callOi * 100 * spot * spot * 0.01; const putGex = PUT_SIGN * gamma * putOi * 100 * spot * spot * 0.01; // Bias toward call/put wall locations const wallBoostCall = Math.exp(-Math.pow((strike - summary.callWall) / (step * 1.5), 2)) * 1.4; const wallBoostPut = Math.exp(-Math.pow((strike - summary.putWall) / (step * 1.5), 2)) * 1.4; const cg = callGex * (1 + wallBoostCall); const pg = putGex * (1 + wallBoostPut); bars.push({ strike, callGex: Math.round(cg), putGex: Math.round(pg), netGex: Math.round(cg + pg), }); } return { ticker, spot, hvl: summary.hvl, callWall: summary.callWall, putWall: summary.putWall, bars, asOf: new Date().toISOString(), }; } export function computeExpirations(ticker: string): ExpirationsResponse { const summary = computeSummary(ticker); const profile = getProfile(ticker); const rand = rng(dailySeed(ticker, 3)); const baseDtes = [0, 1, 2, 7, 14, 30, 60, 90, 180]; const today = new Date(); const rows: ExpirationRow[] = baseDtes.map((dte) => { const date = new Date(today); date.setUTCDate(date.getUTCDate() + dte); const ivx = profile.ivBase * (1 + (dte / 365) * 0.15) + (rand() - 0.5) * 4; const expectedMovePct = (ivx / 100) * Math.sqrt(Math.max(dte, 1) / 365) * 100; const expectedMove = (expectedMovePct / 100) * summary.spot; const gexScale = Math.exp(-dte / 45); const netGex = Math.round(summary.netGex * gexScale * (0.6 + rand() * 0.8)); const skewBase = profile.skewBias + (rand() - 0.5); const callSkew = profile.ivBase - 1.5 + (rand() - 0.5) * 1.5; const putSkew = profile.ivBase + 2.2 + (rand() - 0.5) * 1.5 + skewBase; return { expiry: date.toISOString().slice(0, 10), dte, netGex, ivx: round2(ivx), skew: round2(putSkew - callSkew), expectedMove: round2(expectedMove), expectedMovePct: round2(expectedMovePct), callWall: roundTo(summary.spot * (1 + 0.01 + rand() * 0.03), strikeStep(summary.spot)), putWall: roundTo(summary.spot * (1 - 0.012 - rand() * 0.035), strikeStep(summary.spot)), callSkew: round2(callSkew), putSkew: round2(putSkew), }; }); return { ticker, spot: summary.spot, rows, asOf: new Date().toISOString(), }; } export function computeScreener(): ScreenerRow[] { return SYMBOLS.map((s) => { const summary = computeSummary(s.ticker); const exp = computeExpirations(s.ticker); const zero = exp.rows.find((r) => r.dte === 0)?.netGex ?? 0; return { ticker: s.ticker, name: s.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: round2(((summary.callWall - summary.spot) / summary.spot) * 100), distanceToPutWall: round2(((summary.putWall - summary.spot) / summary.spot) * 100), expectedMovePct: summary.expectedMovePct, skew: summary.skew, zeroDteNetGex: zero, }; }); } function round1(n: number): number { return Math.round(n * 10) / 10; } function round2(n: number): number { return Math.round(n * 100) / 100; }