// Tanuki Trade API client // // Authenticates to TanukiTrade, fetches GEX level data, and returns it // in GammaDesk's standard format. // // Auth flow (simplified for server-side): // - Config: email + tv_user stored in tanuki_config.json (gitignored) // - Session: cached cookies + XOR key, auto-refreshed on expiry // - XOR decryption: key extracted from GEX Live page, used to decrypt msgpack responses // // Rate limits: ~10 req/min to avoid triggering anti-bot import type { TanukiLevels } from "@shared/schema"; const BASE_URL = "https://app.tanukitrade.com"; const LOGIN_URL = `${BASE_URL}/accounts/login/`; const VERIFY_URL = `${BASE_URL}/accounts/verify/`; const WEBAPP_URL = `${BASE_URL}/webapp/`; const GEX_API = `${BASE_URL}/webapp/api/gex-data/`; const PAGE_URL = `${BASE_URL}/webapp/gex-live/`; const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/136.0.0.0 Safari/537.36"; const DEFAULT_SYMBOLS = ["SPX", "QQQ", "GLD", "SLV", "USO"]; // --------------------------------------------------------------------------- // XOR Decryption (matches Tanuki's tanuki-v1 encoding) // --------------------------------------------------------------------------- function decryptXor(data: Uint8Array, key: Uint8Array): Uint8Array { const result = new Uint8Array(data.length); const kl = key.length; for (let i = 0; i < data.length; i++) { result[i] = (data[i] ^ key[i % kl]) & 0xFF; } return result; } // Simple msgpack unpacker for the subset we need (objects, arrays, strings, numbers) function unpackMsgPack(data: Uint8Array): unknown { let pos = 0; function read(): unknown { const byte = data[pos++]; // Fixmap (0x80-0x8f) if ((byte & 0xf0) === 0x80) { const size = byte & 0x0f; const obj: Record = {}; for (let i = 0; i < size; i++) { const key = read() as string; obj[key] = read(); } return obj; } // Fixarray (0x90-0x9f) if ((byte & 0xf0) === 0x90) { const size = byte & 0x0f; const arr: unknown[] = []; for (let i = 0; i < size; i++) { arr.push(read()); } return arr; } // Fixstr (0xa0-0xbf) if ((byte & 0xe0) === 0xa0) { const len = byte & 0x1f; return readStr(len); } // str8 (0xa9) or fixstr overlap if (byte === 0xa9) { const len = data[pos++]; return readStr(len); } if (byte === 0xab) { const len = (data[pos++] << 8) | data[pos++]; return readStr(len); } // uint8 (0xcc) if (byte === 0xcc) { return data[pos++]; } // uint16 (0xcd) if (byte === 0xcd) { const val = (data[pos++] << 8) | data[pos++]; pos++; // skip one more if needed return val; } // uint32 (0xce) if (byte === 0xce) { return (data[pos++] << 24) | (data[pos++] << 16) | (data[pos++] << 8) | data[pos++]; } // float32 (0xca) if (byte === 0xca) { const buf = new ArrayBuffer(4); const view = new DataView(buf); view.setUint8(0, data[pos]); view.setUint8(1, data[pos + 1]); view.setUint8(2, data[pos + 2]); view.setUint8(3, data[pos + 3]); pos += 4; return view.getFloat32(0, false); } // float64 (0xcb) if (byte === 0xcb) { const buf = new ArrayBuffer(8); const view = new DataView(buf); for (let i = 0; i < 8; i++) view.setUint8(i, data[pos + i]); pos += 8; return view.getFloat64(0, false); } // Negative fixint (0xe0-0xff) if (byte >= 0xe0) { return byte - 0x100; // convert to negative } // Positive fixint (0x00-0x7f) if (byte <= 0x7f) { return byte; } // bool if (byte === 0xc3) return false; if (byte === 0xc2) return true; if (byte === 0xc0) return null; // String with prefix if (byte === 0xa8) { const len = data[pos++]; return readStr(len); } console.warn(`[tanuki] Unknown msgpack byte: 0x${byte.toString(16)} at pos ${pos - 1}`); return null; } function readStr(len: number): string { const start = pos; pos += len; return new TextDecoder().decode(data.slice(start, start + len)); } return read(); } // --------------------------------------------------------------------------- // Session Management // --------------------------------------------------------------------------- interface TanukiSession { cookies: Record; xorKey: number[]; expiresAt: number; // epoch ms } interface TanukiConfig { email: string; tvUser: string; } // In-memory cache let cachedSession: TanukiSession | null = null; let cachedConfig: TanukiConfig | null = null; // Load config from environment or file function loadConfig(): TanukiConfig | null { const envEmail = process.env.TANUKI_EMAIL; const envUser = process.env.TANUKI_TV_USER; if (envEmail && envUser) { return { email: envEmail, tvUser: envUser }; } // Try config file try { const fs = require("fs"); const path = require("path"); const configPath = path.join(__dirname, "..", "tanuki_config.json"); if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); if (config.email && config.tv_user) { return { email: config.email, tvUser: config.tv_user }; } } } catch { // Config file doesn't exist or is invalid } return null; } function isConfigured(): boolean { cachedConfig = loadConfig(); return cachedConfig !== null; } function getConfig(): TanukiConfig | null { return cachedConfig || loadConfig(); } function setConfig(email: string, tvUser: string): void { try { const fs = require("fs"); const path = require("path"); const configPath = path.join(__dirname, "..", "tanuki_config.json"); fs.writeFileSync(configPath, JSON.stringify({ email, tv_user: tvUser, updated_at: new Date().toISOString(), }, null, 2)); fs.chmodSync(configPath, 0o600); // Restrict permissions cachedConfig = { email, tvUser }; cachedSession = null; // Invalidate cached session } catch (err) { console.error("[tanuki] Failed to save config:", err); } } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- async function tanukiFetch(url: string, options: RequestInit = {}): Promise { const session = await getSession(); const headers = { "User-Agent": UA, "X-Requested-With": "XMLHttpRequest", "Accept": "*/*", "Referer": PAGE_URL, ...options.headers, }; // Add cookies if (session?.cookies) { const cookieStr = Object.entries(session.cookies) .map(([k, v]) => `${k}=${v}`) .join("; "); headers["Cookie"] = cookieStr; } const response = await fetch(url, { ...options, headers, redirect: "manual", // Handle redirects ourselves }); // Capture set-cookie headers const setCookies = response.headers.getSetCookie?.() || []; for (const cookie of setCookies) { const nameEq = cookie.indexOf("="); const semi = cookie.indexOf(";"); const nameEnd = semi > 0 ? semi : cookie.length; if (nameEq > 0) { const name = cookie.substring(0, nameEnd).split("=")[0]; const value = cookie.substring(nameEq + 1, nameEnd).trim(); if (cachedSession?.cookies) { cachedSession.cookies[name] = value; } } } return response; } // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- async function getSession(): Promise { // Check existing session if (cachedSession && cachedSession.expiresAt > Date.now()) { return cachedSession; } // Need fresh login const config = getConfig(); if (!config) { return null; } return performLogin(config); } async function performLogin(config: TanukiConfig): Promise { try { console.log("[tanuki] Attempting login..."); // Step 1: GET login page for CSRF token const loginResp = await fetch(LOGIN_URL, { headers: { "User-Agent": UA }, redirect: "follow", }); const loginCookies = loginResp.headers.getSetCookie?.() || []; const cookies: Record = {}; for (const cookie of loginCookies) { const nameEq = cookie.indexOf("="); const semi = cookie.indexOf(";"); const nameEnd = semi > 0 ? semi : cookie.length; if (nameEq > 0) { const name = cookie.substring(0, nameEnd).split("=")[0]; const value = cookie.substring(nameEq + 1, nameEnd).trim(); cookies[name] = value; } } const csrfToken = cookies["csrftoken"]; if (!csrfToken) { console.warn("[tanuki] No CSRF token received"); return null; } // Step 2: POST login const verifyResp = await fetch(LOGIN_URL, { method: "POST", headers: { "User-Agent": UA, "Referer": LOGIN_URL, "Content-Type": "application/x-www-form-urlencoded", Cookie: `csrftoken=${csrfToken}`, }, body: `csrfmiddlewaretoken=${csrfToken}&email=${encodeURIComponent(config.email)}&tv_user=${encodeURIComponent(config.tvUser)}`, redirect: "manual", }); const redirectUrl = verifyResp.headers.get("Location") || ""; if (!redirectUrl.includes("/verify")) { console.warn("[tanuki] Login did not redirect to verify page"); return null; } // Note: In production, this would need the verification code from Gmail // For now, we return null and rely on manual session setup console.log("[tanuki] Login submitted. Verification code required from email."); console.log("[tanuki] Set TANUKI_SESSION_COOKIE env var or use /api/tanuki/config endpoint."); // Check for stored session cookie const storedCookie = process.env.TANUKI_SESSION_COOKIE; if (storedCookie) { const sessionCookies: Record = {}; storedCookie.split(";").forEach(c => { const [name, ...valueParts] = c.trim().split("="); if (name) sessionCookies[name] = valueParts.join("="); }); // Extract XOR key const xorKey = await extractXorKey(sessionCookies); if (xorKey) { cachedSession = { cookies: sessionCookies, xorKey, expiresAt: Date.now() + 12 * 60 * 60 * 1000, // 12 hours }; return cachedSession; } } return null; } catch (err) { console.error("[tanuki] Login failed:", err); return null; } } async function extractXorKey(cookies: Record): Promise { const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; "); const resp = await fetch(PAGE_URL, { headers: { "User-Agent": UA, Cookie: cookieStr, }, }); const text = await resp.text(); const match = text.match(/rc\s*=\s*\[(\d[\d,\s]+)\]/); if (!match) return null; return match[1].split(",").map(s => parseInt(s.trim(), 10)); } // --------------------------------------------------------------------------- // Data fetching // --------------------------------------------------------------------------- async function fetchGexData(symbol: string, method = "cumulative", expiry?: string): Promise { const session = await getSession(); if (!session) { throw new Error("Tanuki not configured or session expired. Configure via Settings."); } let url = `${GEX_API}?symbol=${encodeURIComponent(symbol)}&method=${method}`; if (expiry) url += `&expiry=${expiry}`; const resp = await tanukiFetch(url); if (resp.status === 500) { // Session expired, try re-login cachedSession = null; const newSession = await getSession(); if (!newSession) { throw new Error("Tanuki session expired and re-auth failed"); } return fetchGexData(symbol, method, expiry); } if (resp.status !== 200) { throw new Error(`Tanuki API error: ${resp.status}`); } const encoding = resp.headers.get("X-Encoding") || ""; const buffer = await resp.arrayBuffer(); if (encoding === "tanuki-v1") { const decrypted = decryptXor(new Uint8Array(buffer), new Uint8Array(session.xorKey)); return unpackMsgPack(decrypted); } return JSON.parse(new TextDecoder().decode(buffer)); } // --------------------------------------------------------------------------- // Level extraction and classification // --------------------------------------------------------------------------- function classifyRegime(spot: number, c1: number, hvl: number, ctrans: number, netGex: number): string { if (spot > c1) return "Positive Extension"; if (spot < hvl) { return netGex > 0 ? "Negative Transition" : "Negative Gamma"; } if (Math.abs(spot - ctrans) / spot < 0.003) return "Transition"; return "Positive Gamma"; } export function extractTanukiLevels(rawData: any): TanukiLevels { const kl = rawData.key_levels || {}; const spot = rawData.spot_price || 0; const totals = rawData.totals || {}; const c1 = parseFloat(kl.call_resistance || 0); const ctrans = parseFloat(kl.ctrans || 0); const hvl = parseFloat(kl.hvl || 0); const ptrans = parseFloat(kl.ptrans || 0); const p1 = parseFloat(kl.put_support || 0); const c2 = parseFloat(kl.c2 || 0); const c3 = parseFloat(kl.c3 || 0); const p2 = parseFloat(kl.p2 || 0); const p3 = parseFloat(kl.p3 || 0); const netGex = totals.net_gex || 0; const netDex = totals.net_dex || 0; const regime = classifyRegime(spot, c1, hvl, ctrans, netGex); // Build 5 key levels const levels: TanukiLevels["levels"] = []; const levelDefs = [ { label: "C1", role: "Key Resistance", strike: c1 }, { label: "cTrans", role: "Minor Resistance", strike: ctrans }, { label: "HVL", role: "Gamma Flip", strike: hvl }, { label: "pTrans", role: "Minor Support", strike: ptrans }, { label: "P1", role: "Key Support", strike: p1 }, ]; for (const ld of levelDefs) { if (ld.strike > 0) { levels.push({ role: ld.role, strike: ld.strike, label: ld.label }); } } // Extended levels const extendedLevels: TanukiLevels["extendedLevels"] = []; const extDefs = [ { label: "C2", strike: c2 }, { label: "C3", strike: c3 }, { label: "P2", strike: p2 }, { label: "P3", strike: p3 }, ]; for (const ed of extDefs) { if (ed.strike > 0) { extendedLevels.push({ label: ed.label, strike: ed.strike }); } } return { symbol: rawData.symbol || symbol, spotPrice: spot, expiry: rawData.expiry || "", expiryDte: rawData.expiry_dte || 0, regime, updatedAt: rawData.updated_at || new Date().toISOString(), levels, extendedLevels, netGex, netDex, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- async function getLevels(symbol: string): Promise { const raw = await fetchGexData(symbol.toUpperCase()); return extractTanukiLevels(raw); } async function getMultiLevels(symbols: string[]): Promise> { const results: Record = {}; for (const sym of symbols) { try { results[sym] = await getLevels(sym); } catch (err: any) { results[sym] = { error: err.message || String(err) }; } } return results; } export const tanukiClient = { isConfigured, getConfig, setConfig, getLevels, getMultiLevels, extractTanukiLevels, DEFAULT_SYMBOLS, };