533 lines
15 KiB
TypeScript
533 lines
15 KiB
TypeScript
// 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<string, unknown> = {};
|
|
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<string, string>;
|
|
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<Response> {
|
|
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<TanukiSession | null> {
|
|
// 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<TanukiSession | null> {
|
|
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<string, string> = {};
|
|
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<string, string> = {};
|
|
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<string, string>): Promise<number[] | null> {
|
|
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<unknown> {
|
|
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<TanukiLevels> {
|
|
const raw = await fetchGexData(symbol.toUpperCase());
|
|
return extractTanukiLevels(raw);
|
|
}
|
|
|
|
async function getMultiLevels(symbols: string[]): Promise<Record<string, TanukiLevels | { error: string }>> {
|
|
const results: Record<string, TanukiLevels | { error: string }> = {};
|
|
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,
|
|
};
|