288 lines
10 KiB
TypeScript
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;
|
|
}
|