gammanexus/server/marketData.ts

288 lines
10 KiB
TypeScript

// 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;
}