gammanexus/server/routes.ts

440 lines
16 KiB
TypeScript

import type { Express, Request, Response } from "express";
import { createServer } from "node:http";
import type { Server } from "node:http";
import * as session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";
import {
SYMBOLS,
computeExpirations as mockExpirations,
computeGexProfile as mockGexProfile,
computeScreener as mockScreener,
computeSummary as mockSummary,
} from "./marketData";
import { oratsClient } from "./oratsClient";
import { tanukiClient } from "./tanukiClient";
import { registerAuthRoutes, requireAuth } from "./authRoutes";
import { initDb, getAllCustomSymbols, createCustomSymbol, deleteCustomSymbol } from "./db";
import { rateLimit, securityHeaders } from "./middleware";
// Built-in tickers from marketData
const BUILTIN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker));
// Cache of all symbols (built-in + custom) — refreshed on demand
let cachedAllSymbols: any[] | null = null;
let cachedKnownTickers: Set<string> | null = null;
async function refreshSymbolCache() {
const customSymbolsList = await getAllCustomSymbols().catch(() => []);
// Merge built-in + custom, deduplicate by ticker
const builtInMap = new Map(SYMBOLS.map(s => [s.ticker, { ...s, custom: false }]));
for (const cs of customSymbolsList) {
if (!builtInMap.has(cs.ticker)) {
builtInMap.set(cs.ticker, {
ticker: cs.ticker,
name: cs.name,
type: cs.type as "index" | "etf" | "equity",
sector: cs.sector || "Custom",
custom: true,
});
}
}
cachedAllSymbols = Array.from(builtInMap.values());
cachedKnownTickers = new Set(cachedAllSymbols.map(s => s.ticker));
}
function getKnownTickers(): Set<string> {
return cachedKnownTickers || BUILTIN_TICKERS;
}
function getAllSymbols(): any[] {
return cachedAllSymbols || SYMBOLS.map(s => ({ ...s, custom: false }));
}
function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown) {
const raw = String(req.params.symbol || "").toUpperCase();
if (!getKnownTickers().has(raw)) {
res.status(404).json({ error: `Unknown symbol: ${raw}` });
return;
}
try {
res.json(fn(raw));
} catch (err) {
res.status(500).json({ error: "Internal server error" });
}
}
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
await initDb();
await refreshSymbolCache();
// ── Security Headers ──────────────────────────────────────────────
app.use(securityHeaders);
// ── Rate Limiting ─────────────────────────────────────────────────
// General rate limit: 500 requests per 15 minutes (relaxed for dev)
// Home subnet (192.168.x.x) is whitelisted in middleware.ts
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 500 }));
// Auth-specific rate limit: 50 requests per 15 minutes (relaxed for dev)
app.use("/api/auth/", rateLimit({ windowMs: 15 * 60 * 1000, max: 50 }));
// ── Session Configuration (Redis-backed for persistence) ──────────
const redisClient = createClient({
url: process.env.REDIS_URL || "redis://127.0.0.1:6379",
});
redisClient.connect().catch((err) => {
console.error("[session] Redis connection failed, sessions will be in-memory only:", err);
});
const redisSessionStore = new RedisStore({ client: redisClient });
app.use(
session.default?.({
store: redisSessionStore,
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true, // Prevent XSS from accessing session cookie
sameSite: "lax", // Allow same-site + top-level navigation
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
},
}) || session({
store: redisSessionStore,
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}),
);
// ── Body Parsing ──────────────────────────────────────────────────
app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: false, limit: "1mb" }));
// Auth routes
registerAuthRoutes(app);
// Public API routes
app.get("/api/symbols", async (_req, res) => {
await refreshSymbolCache();
res.json(getAllSymbols());
});
// Add a custom symbol
app.post("/api/symbols", requireAuth, async (req, res) => {
const { ticker, name, type, sector } = req.body as Record<string, string>;
if (!ticker || !name) {
return res.status(400).json({ error: "ticker and name are required" });
}
const normalizedTicker = ticker.trim().toUpperCase();
if (normalizedTicker.length > 8) {
return res.status(400).json({ error: "ticker must be 8 characters or less" });
}
try {
const symbol = await createCustomSymbol({
ticker: normalizedTicker,
name: name.trim(),
type: (type as any) || "etf",
sector: sector?.trim() || null,
});
await refreshSymbolCache();
res.json({ ok: true, symbol });
} catch (err: any) {
if (err.code === '23505') {
return res.status(409).json({ error: `Symbol ${normalizedTicker} already exists` });
}
console.error("Error creating custom symbol:", err);
res.status(500).json({ error: "Failed to create symbol" });
}
});
// Delete a custom symbol
app.delete("/api/symbols/:ticker", requireAuth, async (req, res) => {
const ticker = String(req.params.ticker || "").toUpperCase();
if (BUILTIN_TICKERS.has(ticker)) {
return res.status(400).json({ error: "Cannot remove built-in symbol" });
}
try {
const deleted = await deleteCustomSymbol(ticker);
if (!deleted) {
return res.status(404).json({ error: `Custom symbol ${ticker} not found` });
}
await refreshSymbolCache();
res.json({ ok: true });
} catch (err) {
console.error("Error deleting custom symbol:", err);
res.status(500).json({ error: "Failed to delete symbol" });
}
});
// ORATS status - hide sensitive info
app.get("/api/orats/status", (_req, res) => {
res.json({
configured: oratsClient.isConfigured(),
baseUrl: oratsClient.isConfigured() ? "https://api.orats.io/datav2" : "not-configured",
});
});
// Save ORATS API key + base URL to session
app.post("/api/orats/config", requireAuth, (req, res) => {
const { apiKey, baseUrl } = req.body as Record<string, string>;
const session = req.session as Record<string, unknown>;
if (!apiKey || typeof apiKey !== "string") {
return res.status(400).json({ error: "API key is required" });
}
const sanitizedApiKey = apiKey.trim().replace(/[<>]/g, "");
session.oratsApiKey = sanitizedApiKey;
// Update the orats client with the new key
oratsClient.setApiKey(sanitizedApiKey);
if (baseUrl && typeof baseUrl === "string") {
oratsClient.setBaseUrl(baseUrl);
}
res.json({ ok: true, configured: oratsClient.isConfigured() });
});
// Clear ORATS API key from session
app.delete("/api/orats/config", requireAuth, (req, res) => {
const session = req.session as Record<string, unknown>;
delete session.oratsApiKey;
oratsClient.setApiKey("");
res.json({ ok: true, configured: oratsClient.isConfigured() });
});
// ── Tanuki Trades Integration ─────────────────────────────────────
// Tanuki status
app.get("/api/tanuki/status", (_req, res) => {
res.json({
configured: tanukiClient.isConfigured(),
});
});
// Save Tanuki config
app.post("/api/tanuki/config", requireAuth, (req, res) => {
const { email, tvUser } = req.body as Record<string, string>;
if (!email || !tvUser) {
return res.status(400).json({ error: "email and tvUser are required" });
}
tanukiClient.setConfig(email.trim(), tvUser.trim());
res.json({ ok: true, configured: tanukiClient.isConfigured() });
});
// Clear Tanuki config
app.delete("/api/tanuki/config", (_req, res) => {
try {
const fs = require("fs");
const path = require("path");
const configPath = path.join(__dirname, "..", "tanuki_config.json");
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
} catch { /* ignore */ }
res.json({ ok: true, configured: tanukiClient.isConfigured() });
});
// Fetch Tanuki levels for a symbol — falls back to ORATS-derived levels if Tanuki not configured
app.get("/api/tanuki/:symbol", async (req, res) => {
const symbol = String(req.params.symbol || "").toUpperCase();
try {
let levels;
if (tanukiClient.isConfigured()) {
levels = await tanukiClient.getLevels(symbol);
} else {
// Fall back to ORATS-derived Tanuki levels
levels = await oratsClient.computeTanukiLevels(symbol);
}
res.json(levels);
} catch (err: any) {
// If Tanuki fails, try ORATS as fallback
if (tanukiClient.isConfigured() && oratsClient.isConfigured()) {
try {
const fallback = await oratsClient.computeTanukiLevels(symbol);
res.json(fallback);
return;
} catch { /* ignore */ }
}
console.error(`Tanuki error for ${symbol}:`, err);
res.status(502).json({ error: err.message || "Failed to fetch levels" });
}
});
// Fetch Tanuki levels for multiple symbols — uses ORATS
app.get("/api/tanuki", async (req, res) => {
const symbols = (req.query.symbols as string)
?.split(",").map(s => s.trim().toUpperCase()).filter(Boolean)
|| ["SPX", "QQQ", "GLD", "SLV", "USO"];
try {
const results: Record<string, any> = {};
await Promise.all(symbols.map(async (sym) => {
try {
results[sym] = await oratsClient.computeTanukiLevels(sym);
} catch (err: any) {
results[sym] = { error: err.message || "Failed to fetch" };
}
}));
res.json(results);
} catch (err: any) {
console.error("Tanuki multi-fetch error:", err);
res.status(502).json({ error: err.message || "Failed to fetch Tanuki data" });
}
});
// Market data routes
app.get("/api/market/:symbol/summary", async (req, res) => {
const ticker = String(req.params.symbol || "").toUpperCase();
if (!getKnownTickers().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));
}
});
app.get("/api/market/:symbol/gex", async (req, res) => {
const ticker = String(req.params.symbol || "").toUpperCase();
if (!getKnownTickers().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));
}
});
app.get("/api/market/:symbol/expirations", async (req, res) => {
const ticker = String(req.params.symbol || "").toUpperCase();
if (!getKnownTickers().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));
}
});
app.get("/api/screener", async (_req, res) => {
try {
await refreshSymbolCache();
const allSymbols = getAllSymbols();
if (oratsClient.isConfigured()) {
const summaries = await Promise.all(
allSymbols.map((s) => oratsClient.computeSummary(s.ticker)),
);
const screenerData = summaries.map((summary, i) => {
const symbol = allSymbols[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,
};
});
res.json(screenerData);
} else {
res.json(mockScreener());
}
} catch (err) {
console.error("ORATS screener error:", err);
res.json(mockScreener());
}
});
// ── Futures Levels (Tanuki-style: ETF gamma → futures equivalents) ──
app.get("/api/futures/:symbol/levels", async (req, res) => {
const ticker = String(req.params.symbol || "").toUpperCase();
if (!getKnownTickers().has(ticker)) {
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
}
// Import conversions from shared schema
const { FUTURES_CONVERSIONS } = await import("@shared/schema");
const conversion = FUTURES_CONVERSIONS.find((c) => c.sourceTicker === ticker);
if (!conversion) {
return res.status(400).json({ error: `No futures mapping for ${ticker}. Supported: ${FUTURES_CONVERSIONS.map((c) => c.sourceTicker).join(", ")}` });
}
try {
const [summary, gex] = await Promise.all([
oratsClient.isConfigured()
? oratsClient.computeSummary(ticker)
: Promise.resolve(mockSummary(ticker)),
oratsClient.isConfigured()
? oratsClient.computeGexProfile(ticker)
: Promise.resolve(mockGexProfile(ticker)),
]);
const { multiplier } = conversion;
const sourceSpot = summary.spot;
// Get top strikes by absolute net GEX
const topStrikes = [...gex.bars]
.map((b) => ({
sourceStrike: b.strike,
futuresStrike: Math.round(b.strike * multiplier * 100) / 100,
netGex: b.netGex,
magnitude: Math.abs(b.netGex),
}))
.sort((a, b) => b.magnitude - a.magnitude)
.slice(0, 12);
const futuresLevel = {
sourceTicker: ticker,
targetTicker: conversion.targetTicker,
targetName: conversion.targetName,
sourceSpot,
futuresSpot: Math.round(sourceSpot * multiplier * 100) / 100,
hvl: Math.round(gex.hvl * multiplier * 100) / 100,
callWall: Math.round(gex.callWall * multiplier * 100) / 100,
putWall: Math.round(gex.putWall * multiplier * 100) / 100,
sourceHvl: gex.hvl,
sourceCallWall: gex.callWall,
sourcePutWall: gex.putWall,
topStrikes,
asOf: gex.asOf,
};
res.json(futuresLevel);
} catch (err) {
console.error(`Futures levels error for ${ticker}:`, err);
res.status(500).json({ error: "Failed to compute futures levels" });
}
});
return httpServer;
}
// Import express for body parsing
import express from "express";