diff --git a/.gitignore b/.gitignore index dd497bf..09bec7c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ Thumbs.db # Logs *.log + +# Research materials +tanuki_research/ diff --git a/client/src/App.tsx b/client/src/App.tsx index 25f87f6..5290a3f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,6 +15,8 @@ import DashboardPage from "@/pages/dashboard"; import GammaLevelsPage from "@/pages/gamma-levels"; import ExpiryMatrixPage from "@/pages/expiry-matrix"; import ScreenerPage from "@/pages/screener"; +import FuturesLevelsPage from "@/pages/futures-levels"; +import TanukiTradesPage from "@/pages/tanuki-trades"; import SettingsPage from "@/pages/settings"; import AccountPage from "@/pages/account"; import LoginPage from "@/pages/login"; @@ -27,6 +29,8 @@ function usePageTitle(): string { if (location.startsWith("/gamma")) return "Gamma Levels"; if (location.startsWith("/expiry")) return "Expiry Matrix"; if (location.startsWith("/screener")) return "Screener"; + if (location.startsWith("/futures")) return "Futures Levels"; + if (location.startsWith("/tanuki")) return "Tanuki Trades"; if (location.startsWith("/settings")) return "Settings & API"; if (location.startsWith("/account")) return "Account"; if (location.startsWith("/login")) return "Sign In"; @@ -91,6 +95,8 @@ function AppShell() { } /> } /> } /> + } /> + } /> } /> diff --git a/client/src/components/app-sidebar.tsx b/client/src/components/app-sidebar.tsx index b496111..78d88d9 100644 --- a/client/src/components/app-sidebar.tsx +++ b/client/src/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { LayoutDashboard, Activity, CalendarRange, ListFilter, Settings2, UserCircle } from "lucide-react"; +import { LayoutDashboard, Activity, CalendarRange, ListFilter, TrendingUp as TrendingUpIcon, Target, Settings2, UserCircle } from "lucide-react"; import { Link, useLocation } from "wouter"; import { Sidebar, @@ -19,6 +19,8 @@ const NAV = [ { title: "Gamma Levels", url: "/gamma", icon: Activity, testid: "nav-gamma" }, { title: "Expiry Matrix", url: "/expiry", icon: CalendarRange, testid: "nav-expiry" }, { title: "Screener", url: "/screener", icon: ListFilter, testid: "nav-screener" }, + { title: "Futures Levels", url: "/futures", icon: TrendingUpIcon, testid: "nav-futures" }, + { title: "Tanuki Trades", url: "/tanuki", icon: Target, testid: "nav-tanuki" }, { title: "Settings & API", url: "/settings", icon: Settings2, testid: "nav-settings" }, { title: "Account", url: "/account", icon: UserCircle, testid: "nav-account" }, ]; diff --git a/client/src/components/symbol-selector.tsx b/client/src/components/symbol-selector.tsx index 5231758..9332fd0 100644 --- a/client/src/components/symbol-selector.tsx +++ b/client/src/components/symbol-selector.tsx @@ -1,4 +1,5 @@ -import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { SymbolInfo } from "@shared/schema"; import { useSymbol } from "@/lib/symbol-context"; import { @@ -10,38 +11,198 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Trash2, Plus, X } from "lucide-react"; export function SymbolSelector() { const { symbol, setSymbol } = useSymbol(); + const queryClient = useQueryClient(); + const [newTicker, setNewTicker] = useState(""); + const [newName, setNewName] = useState(""); + const [showAdd, setShowAdd] = useState(false); + const { data } = useQuery({ queryKey: ["/api/symbols"] }); const symbols = data ?? []; + const builtInSymbols = symbols.filter((s) => !s.custom); + const customSymbols = symbols.filter((s) => s.custom); + + const addMutation = useMutation({ + mutationFn: async (body: { ticker: string; name: string }) => { + const res = await fetch("/api/symbols", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to add symbol"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/symbols"] }); + setNewTicker(""); + setNewName(""); + setShowAdd(false); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (ticker: string) => { + const res = await fetch(`/api/symbols/${ticker}`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Failed to delete symbol"); + } + return res.json(); + }, + onSuccess: (_data, ticker) => { + queryClient.invalidateQueries({ queryKey: ["/api/symbols"] }); + // If deleted symbol is currently selected, switch to SPY + if (symbol === ticker) { + setSymbol("SPY"); + } + }, + }); + + const handleAdd = () => { + if (!newTicker.trim() || !newName.trim()) return; + addMutation.mutate({ ticker: newTicker, name: newName }); + }; return ( - + + + + + + Default Symbols + {builtInSymbols.map((s) => ( + +
+ {s.ticker} + {s.name} +
+
+ ))} +
+ {customSymbols.length > 0 && ( + + Custom + {customSymbols.map((s) => ( + +
+
+ {s.ticker} + {s.name} + custom +
+ +
+
+ ))} +
+ )} +
+ + + {/* Add Symbol Button / Form */} +
+ {!showAdd ? ( + + ) : ( +
+ setNewTicker(e.target.value.toUpperCase().slice(0, 8))} + className="h-7 w-20 text-xs font-mono" + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + if (e.key === "Escape") { + setShowAdd(false); + setNewTicker(""); + setNewName(""); + } + }} + /> + setNewName(e.target.value)} + className="h-7 flex-1 text-xs" + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + if (e.key === "Escape") { + setShowAdd(false); + setNewTicker(""); + setNewName(""); + } + }} + /> + + + {addMutation.isPending && ( + Adding... + )} + {addMutation.error && ( + {addMutation.error.message} + )} +
+ )} +
+ ); } diff --git a/client/src/pages/futures-levels.tsx b/client/src/pages/futures-levels.tsx new file mode 100644 index 0000000..0ae8216 --- /dev/null +++ b/client/src/pages/futures-levels.tsx @@ -0,0 +1,459 @@ +import { useQuery } from "@tanstack/react-query"; +import { useSymbol } from "@/lib/symbol-context"; +import { fmtCurrency, fmtCompactCurrency, fmtPct } from "@/lib/format"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Bar, + BarChart, + CartesianGrid, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ArrowRight, TrendingUp, TrendingDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { LoadingBlock, LoadingCards } from "@/components/loading-block"; + +// Type for the futures levels response +interface FuturesLevels { + sourceTicker: string; + targetTicker: string; + targetName: string; + sourceSpot: number; + futuresSpot: number; + hvl: number; + callWall: number; + putWall: number; + sourceHvl: number; + sourceCallWall: number; + sourcePutWall: number; + topStrikes: { + sourceStrike: number; + futuresStrike: number; + netGex: number; + magnitude: number; + }[]; + asOf: string; +} + +export default function FuturesLevelsPage() { + const { symbol } = useSymbol(); + const levels = useQuery({ + queryKey: ["/api/futures", symbol, "levels"], + }); + + const data = levels.data; + const isLoading = levels.isLoading; + const error = levels.error; + + return ( +
+ {/* Header */} +
+
+

+ Futures Gamma Levels +

+

+ Gamma-derived levels from {symbol} options mapped to futures + equivalents using ORATS data. +

+
+ {data && ( +
+ + {symbol} + + + + {data.targetTicker} + + + {data.targetName} + +
+ )} +
+ + {/* Error state */} + {error && ( + + +

+ {levels.error.message.includes("No futures mapping") + ? `${symbol} does not have a futures mapping. Currently supported: SPY → ES, QQQ → NQ, SPX → ES, IWM → YM` + : "Failed to load futures levels. Check your ORATS configuration."} +

+
+
+ )} + + {/* Key Levels Cards */} + {isLoading && !error ? ( + + ) : data ? ( +
+ + + + +
+ ) : null} + + {/* Levels Summary */} + {data && ( + + + + Key Levels on {data.targetTicker} Futures + + + Gamma-derived levels from {symbol} options market, converted to{" "} + {data.targetTicker} futures prices. Use these as reference levels + on your futures charts. + + + +
+ } + /> + } + /> + } + /> +
+
+
+ )} + + {/* GEX Distribution Chart (futures scale) */} + {data && data.topStrikes.length > 0 && ( + + + + Gamma Distribution ({data.targetTicker} strikes) + + + Top {data.topStrikes.length} strikes ranked by absolute net + gamma, shown at {data.targetTicker} futures prices. + + + + + + + )} + + {/* Strikes Table */} + {data && data.topStrikes.length > 0 && ( + + + + Top Gamma Strikes ({data.targetTicker}) + + + Both source ({symbol}) and futures ({data.targetTicker}) prices. + Positive net GEX = dealer long gamma (stabilizing). Negative = + dealer short gamma (accelerating). + + + +
+ + + + + {symbol} Strike + + + {data.targetTicker} Strike + + Net GEX + Distance + + + + {data.topStrikes.map((row) => { + const dist = + ((row.futuresStrike - data.futuresSpot) / + data.futuresSpot) * + 100; + return ( + + + {fmtCurrency(row.sourceStrike)} + + + {fmtCurrency(row.futuresStrike)} + + = 0 + ? "text-pos" + : "text-neg", + )} + > + {fmtCompactCurrency(row.netGex)} + + + {fmtPct(dist)} + + + ); + })} + +
+
+
+
+ )} + + {/* Footer note */} + {data && ( +
+

+ How to use: Plot the {data.targetTicker} strike + levels above on your futures chart. The HVL ({fmtCurrency(data.hvl)}){" "} + acts as a gamma pin/magnet. Call Wall ({fmtCurrency(data.callWall)}){" "} + is gamma-based resistance. Put Wall ({fmtCurrency(data.putWall)}) is{" "} + gamma-based support. These levels are derived from {symbol} options + open interest and gamma exposure via ORATS — they represent where + dealer hedging activity tends to be most concentrated. +

+
+ )} +
+ ); +} + +// ── Sub-components ────────────────────────────────────────────────── + +function FuturesMetricCard({ + label, + value, + futuresValue, + sublabel, + accent, +}: { + label: string; + value: string; + futuresValue: string; + sublabel: string; + accent?: string; +}) { + return ( + + +

+ {label} +

+
+ {value} + + + {futuresValue} + +
+

{sublabel}

+
+
+ ); +} + +function LevelDetail({ + label, + value, + spot, + color, + icon, +}: { + label: string; + value: number; + spot: number; + color: string; + icon: React.ReactNode; +}) { + const dist = ((value - spot) / spot) * 100; + return ( +
+
+ {icon} + {label} +
+ + {fmtCurrency(value)} + + {fmtPct(dist)} +
+ ); +} + +function FuturesGexChart({ + strikes, +}: { + strikes: { + sourceStrike: number; + futuresStrike: number; + netGex: number; + magnitude: number; + }[]; +}) { + const data = strikes + .sort((a, b) => a.futuresStrike - b.futuresStrike) + .map((s) => ({ + strike: s.futuresStrike, + netGex: s.netGex, + })); + + return ( +
+ + + + fmtCurrency(v)} + minTickGap={24} + /> + fmtCompactCurrency(v as number)} + width={68} + /> + + + { + const { fill, x, y, width, height } = props; + const yPos = props.payload.netGex >= 0 ? y : y + height; + const h = props.payload.netGex >= 0 ? height : -height; + const color = props.payload.netGex >= 0 + ? "hsl(var(--pos))" + : "hsl(var(--neg))"; + return ; + }} + /> + + +
+ ); +} + +function FuturesTooltip({ active, payload }: any) { + if (!active || !payload?.length) return null; + const row = payload[0]?.payload; + if (!row) return null; + return ( +
+
+ Strike {fmtCurrency(row.strike)} +
+
+ = 0 ? "text-pos" : "text-neg"} + > + Net GEX + + = 0 ? "text-pos font-medium" : "text-neg font-medium"} + > + {fmtCompactCurrency(row.netGex)} + +
+
+ ); +} diff --git a/client/src/pages/tanuki-trades.tsx b/client/src/pages/tanuki-trades.tsx new file mode 100644 index 0000000..07c8257 --- /dev/null +++ b/client/src/pages/tanuki-trades.tsx @@ -0,0 +1,526 @@ +import { useState, useMemo, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { TanukiLevels } from "@shared/schema"; +import { useSymbol } from "@/lib/symbol-context"; +import { fmtCompactCurrency, fmtPct } from "@/lib/format"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { LoadingBlock } from "@/components/loading-block"; +import { cn } from "@/lib/utils"; +import { + ArrowUp, ArrowDown, Activity, TrendingUp, TrendingDown, Minus, + Shield, AlertTriangle, Zap, ChevronRight, RefreshCw +} from "lucide-react"; + +const REGIME_COLORS: Record = { + "Positive Gamma": "text-emerald-400 bg-emerald-400/10 border-emerald-400/20", + "Negative Gamma": "text-rose-400 bg-rose-400/10 border-rose-400/20", + "Positive Extension": "text-amber-400 bg-amber-400/10 border-amber-400/20", + "Transition": "text-sky-400 bg-sky-400/10 border-sky-400/20", + "Negative Transition": "text-orange-400 bg-orange-400/10 border-orange-400/20", +}; + +const LEVEL_COLORS: Record = { + "Key Resistance": "text-red-400 bg-red-400/10", + "Minor Resistance": "text-orange-400 bg-orange-400/10", + "Gamma Flip": "text-yellow-400 bg-yellow-400/10", + "Minor Support": "text-green-400 bg-green-400/10", + "Key Support": "text-emerald-400 bg-emerald-400/10", +}; + +const LEVEL_LABELS: Record = { + "C1": "Call Resistance", + "cTrans": "Call Transition", + "HVL": "High Volume Level", + "pTrans": "Put Transition", + "P1": "Put Support", +}; + +export default function TanukiTradesPage() { + const { symbol } = useSymbol(); + const [compareMode, setCompareMode] = useState(false); + + // Fetch all available symbols + const symbolsQuery = useQuery({ + queryKey: ["/api/symbols"], + queryFn: () => fetch("/api/symbols").then(r => r.json()).then((data: any[]) => + data.map((s: any) => s.ticker) + ), + staleTime: 60 * 1000, + }); + const allSymbols = symbolsQuery.data ?? ["SPY", "QQQ", "SPX", "TSLA", "NVDA"]; + + const [selectedSymbol, setSelectedSymbol] = useState(symbol || allSymbols[0]); + + // Sync selectedSymbol when system symbol changes + useEffect(() => { + if (!compareMode) { + setSelectedSymbol(symbol || allSymbols[0]); + } + }, [symbol, compareMode, allSymbols]); + + // Single symbol query — uses ORATS-derived levels (fallback when Tanuki not configured) + const tanukiQuery = useQuery({ + queryKey: ["/api/tanuki", selectedSymbol], + queryFn: () => fetch(`/api/tanuki/${selectedSymbol}`).then(r => { + if (!r.ok) throw new Error(r.statusText); + return r.json(); + }), + enabled: !compareMode, + staleTime: 2 * 60 * 1000, + }); + + // Multi-symbol comparison query — uses all symbols + const compareQuery = useQuery>({ + queryKey: ["/api/tanuki", "compare"], + queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`).then(r => { + if (!r.ok) throw new Error(r.statusText); + return r.json(); + }), + enabled: compareMode, + staleTime: 2 * 60 * 1000, + }); + + // Status query + const statusQuery = useQuery({ + queryKey: ["/api/tanuki/status"], + queryFn: () => fetch("/api/tanuki/status").then(r => r.json()), + staleTime: 60 * 1000, + }); + + const tanukiConfigured = statusQuery.data?.configured; + const oratsStatusQuery = useQuery({ + queryKey: ["/api/orats/status"], + queryFn: () => fetch("/api/orats/status").then(r => r.json()), + staleTime: 60 * 1000, + }); + const oratsConfigured = oratsStatusQuery.data?.configured; + + return ( +
+ {/* Header */} +
+
+
+

Gamma Levels

+ + Tanuki-style + + {!tanukiConfigured && oratsConfigured && ( + + Powered by ORATS + + )} + {!tanukiConfigured && !oratsConfigured && ( + + No Data Source + + )} +
+

+ 5-level gamma exposure profiles with regime classification and extended support/resistance zones. + {tanukiConfigured ? " Data from Tanuki Trades." : " Derived from ORATS options data."} +

+
+ +
+ {!compareMode && ( + + )} + +
+
+ + {/* Comparison view */} + {compareMode ? ( + + ) : ( + /* Single symbol view */ + + )} + + {/* Legend */} + + + Level Legend + + +
+ {[ + { label: "C1 — Call Resistance", desc: "Primary ceiling. Dealers hedge heavily here.", color: "bg-red-400" }, + { label: "cTrans — Call Transition", desc: "Call hedge transition point.", color: "bg-orange-400" }, + { label: "HVL — Gamma Flip", desc: "Where dealer behavior flips: above = stable, below = volatile.", color: "bg-yellow-400" }, + { label: "pTrans — Put Transition", desc: "Put hedge transition point.", color: "bg-green-400" }, + { label: "P1 — Put Support", desc: "Primary floor. Dealers hedge heavily here.", color: "bg-emerald-400" }, + ].map((item) => ( +
+
+
+

{item.label}

+

{item.desc}

+
+
+ ))} +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// Single Symbol View +// --------------------------------------------------------------------------- + +function SingleView({ + data, + loading, + error, + symbol, +}: { + data: TanukiLevels | undefined; + loading: boolean; + error: Error | null; + symbol: string; +}) { + if (loading) return ; + if (error) return ; + if (!data) return ; + + return ( +
+ {/* Left: Level visualization */} + + +
+
+ 5-Level Profile — {data.symbol} +

+ Spot: {data.spotPrice.toFixed(2)} · Expiry: {data.expiry} ({data.expiryDte} DTE) +

+
+ +
+
+ + + +
+ + {/* Right: Metrics */} +
+ {/* GEX Metrics */} + + + + Gamma Metrics + + + + = 0 ? "Positive gamma — mean reverting" : "Negative gamma — trend amplifying"} + positive={data.netGex >= 0} + /> + + + + + {/* Extended Levels */} + {data.extendedLevels.length > 0 && ( + + + Extended Levels + + +
+ {data.extendedLevels.map((el) => ( +
+ {el.label} + {el.strike ? el.strike.toFixed(2) : "—"} +
+ ))} +
+
+
+ )} + + {/* Timestamp */} +
+ Updated: {new Date(data.updatedAt).toLocaleString()} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Compare View +// --------------------------------------------------------------------------- + +function CompareView({ + data, + loading, +}: { + data: Record | undefined; + loading: boolean; +}) { + if (loading) return ; + if (!data) return ; + + return ( + + + Multi-Symbol Comparison + + +
+ + + + + + + + + + + + + + + + {Object.entries(data).map(([sym, result]) => ( + + + {"error" in result ? ( + + ) : ( + <> + + + {result.levels.map((l) => ( + + ))} + + + )} + + ))} + +
SymbolSpotRegimeC1cTransHVLpTransP1Net GEX
{sym}{result.error}{result.spotPrice.toFixed(2)} + {l.strike.toFixed(2)} + = 0 ? "text-emerald-400" : "text-rose-400" + )}> + {fmtCompactCurrency(result.netGex)} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Level Ladder Visualization +// --------------------------------------------------------------------------- + +function LevelLadder({ + levels, + spot, + extended, +}: { + levels: TanukiLevels["levels"]; + spot: number; + extended: TanukiLevels["extendedLevels"]; +}) { + // Sort levels by strike descending (resistance on top, support on bottom) + const sorted = [...levels].sort((a, b) => b.strike - a.strike); + + const allStrikes = [...levels.map(l => l.strike), ...extended.filter(e => e.strike).map(e => e.strike!)]; + const minStrike = Math.min(...allStrikes) * 0.98; + const maxStrike = Math.max(...allStrikes) * 1.02; + const range = maxStrike - minStrike; + + const pctPosition = (strike: number) => ((strike - minStrike) / range) * 100; + + return ( +
+ {/* Spot line */} +
+
+ + SPOT {spot.toFixed(2)} + +
+ + {/* Levels */} + {sorted.map((level) => { + const distPct = ((level.strike - spot) / spot) * 100; + const colorClass = LEVEL_COLORS[level.role] || "text-muted-foreground"; + + return ( +
+
+ {/* Label */} +
+ {level.label} +
+ + {/* Line */} +
+
+
+ + {/* Strike */} +
+ {level.strike.toFixed(2)} + {fmtPct(distPct)} +
+ + {/* Role */} +
+ {level.role} +
+
+
+ ); + })} + + {/* Extended levels (subtle) */} + {extended.filter(e => e.strike).map((el) => ( +
+
+
+ {el.label} +
+
+
+
+
+ {el.strike!.toFixed(2)} +
+
+
+
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Regime Badge +// --------------------------------------------------------------------------- + +function RegimeBadge({ regime }: { regime: string }) { + const colors = REGIME_COLORS[regime] || "text-muted-foreground bg-muted"; + const icon = regime.includes("Positive") ? TrendingUp : + regime.includes("Negative") ? TrendingDown : + regime.includes("Extension") ? ArrowUp : Activity; + const Icon = icon; + + return ( + + + {regime} + + ); +} + +// --------------------------------------------------------------------------- +// Metric Row +// --------------------------------------------------------------------------- + +function MetricRow({ + label, + value, + sub, + positive, +}: { + label: string; + value: string; + sub?: string; + positive?: boolean; +}) { + return ( +
+
+ {label} + {value} +
+ {sub &&

{sub}

} +
+ ); +} + +// --------------------------------------------------------------------------- +// Error State +// --------------------------------------------------------------------------- + +function ErrorState({ error, symbol }: { error: string; symbol: string }) { + return ( + + +
+ +
+

Failed to load {symbol} data

+

{error}

+
+

+ Tanuki Trades requires authentication via email + TradingView username. + Configure in Settings & API, or check that the session is valid. +

+
+
+
+ ); +} diff --git a/server/db.ts b/server/db.ts index 8042f37..5bea859 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,6 +1,6 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { eq } from "drizzle-orm"; -import { users, User, NewUser } from "../shared/schema"; +import { users, customSymbols, User, NewUser, CustomSymbol, NewCustomSymbol } from "../shared/schema"; import { Pool } from "pg"; // PostgreSQL connection (local on production droplet) @@ -37,6 +37,19 @@ export async function initDb(): Promise { await addColumnIfExists('email_verified', 'BOOLEAN NOT NULL DEFAULT FALSE'); await addColumnIfExists('verification_token', 'TEXT'); await addColumnIfExists('verification_token_expiry', 'TIMESTAMP WITH TIME ZONE'); + + // Create custom_symbols table + await db.execute(` + CREATE TABLE IF NOT EXISTS custom_symbols ( + id SERIAL PRIMARY KEY, + ticker TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'etf', + sector TEXT, + user_id TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + `); } async function addColumnIfExists(column: string, typeDef: string): Promise { @@ -90,3 +103,29 @@ export async function getUserByVerificationToken(token: string): Promise { await db.update(users).set({ emailVerified: true, verificationToken: null, verificationTokenExpiry: null }).where(eq(users.id, id)); } + +// --------------------------------------------------------------------------- +// Custom Symbols CRUD +// --------------------------------------------------------------------------- + +export async function getAllCustomSymbols(): Promise { + return await db.select().from(customSymbols); +} + +export async function getCustomSymbolByTicker(ticker: string): Promise { + const result = await db.select().from(customSymbols).where(eq(customSymbols.ticker, ticker.toUpperCase())); + return result[0]; +} + +export async function createCustomSymbol(data: NewCustomSymbol): Promise { + const [symbol] = await db.insert(customSymbols).values({ + ...data, + ticker: data.ticker?.toUpperCase() || data.ticker, + }).returning(); + return symbol; +} + +export async function deleteCustomSymbol(ticker: string): Promise { + const result = await db.delete(customSymbols).where(eq(customSymbols.ticker, ticker.toUpperCase())).returning(); + return result.length > 0; +} diff --git a/server/marketData.ts b/server/marketData.ts index 62604d3..26008c5 100644 --- a/server/marketData.ts +++ b/server/marketData.ts @@ -34,9 +34,10 @@ 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: "SPCX", name: "Invesco S&P 500 Pure Value ETF", type: "etf", sector: "Value" }, { ticker: "TSLA", name: "Tesla, Inc.", type: "equity", sector: "Consumer cyclical" }, { ticker: "NVDA", name: "NVIDIA Corporation", type: "equity", sector: "Semiconductors" }, + { ticker: "PURR", name: "Principal Ultra Short Pure Yield ETF", type: "etf", sector: "Fixed income" }, ]; // Reference spot prices and characteristic IV ranges per ticker. These keep diff --git a/server/oratsClient.ts b/server/oratsClient.ts index 4239b0c..bcf537f 100644 --- a/server/oratsClient.ts +++ b/server/oratsClient.ts @@ -12,7 +12,7 @@ // // Rate limits: 100 req/min per API key -import type { Summary, GexProfile, ExpirationsResponse } from "@shared/schema"; +import type { Summary, GexProfile, ExpirationsResponse, TanukiLevels } from "@shared/schema"; export interface OratsClientConfig { apiKey?: string; @@ -278,6 +278,143 @@ export class OratsClient { }; } + // ── Tanuki-style levels from ORATS GEX data ─────────────────────── + async computeTanukiLevels(ticker: string): Promise { + const [summary, strikesRes] = await Promise.all([ + this.computeSummary(ticker), + this.fetchStrikes(ticker), + ]); + + const spot = summary.spot; + const strikes = strikesRes.data; + + // Filter to near-term strikes with meaningful OI (same as GEX profile) + const strikeFloor = spot * 0.75; + const strikeCeiling = spot * 1.25; + const relevant = strikes + .filter( + (s) => + s.dte <= 7 && + s.strike >= strikeFloor && + s.strike <= strikeCeiling && + s.callOpenInterest + s.putOpenInterest > 500 + ) + .map((s) => { + const gamma = Math.abs(s.gamma || 0); + const callGex = gamma * s.callOpenInterest * spot * spot * 0.01; + const putGex = gamma * s.putOpenInterest * spot * spot * 0.01; + return { + strike: s.strike, + callGex, + putGex, + netGex: callGex - putGex, + }; + }) + .sort((a, b) => a.strike - b.strike); + + // C1 = callWall (strike with highest call GEX overall) + const callWallStrike = summary.callWall; + // P1 = putWall (strike with highest put GEX overall) + const putWallStrike = summary.putWall; + // HVL = gamma flip point + const hvl = summary.hvl; + + // cTrans = strike with highest call gamma below callWall + const belowCallWall = relevant.filter((b) => b.strike < callWallStrike && b.callGex > 0); + const cTrans = belowCallWall.length > 0 + ? belowCallWall.reduce((best, b) => (b.callGex > best.callGex ? b : best)).strike + : callWallStrike; + + // pTrans = strike with highest put gamma above putWall + const abovePutWall = relevant.filter((b) => b.strike > putWallStrike && b.putGex > 0); + const pTrans = abovePutWall.length > 0 + ? abovePutWall.reduce((best, b) => (b.putGex > best.putGex ? b : best)).strike + : putWallStrike; + + // Extended levels: C2, C3 from next-highest call GEX strikes above C1 + const sortedByCallGex = [...relevant].sort((a, b) => b.callGex - a.callGex); + const c2Candidate = sortedByCallGex.find((b) => b.strike > callWallStrike && b.strike !== cTrans); + const c3Candidate = sortedByCallGex.find( + (b) => b.strike > (c2Candidate?.strike ?? callWallStrike) && b.strike !== c2Candidate?.strike + ); + + // P2, P3 from next-highest put GEX strikes below P1 + const sortedByPutGex = [...relevant].sort((a, b) => b.putGex - a.putGex); + const p2Candidate = sortedByPutGex.find((b) => b.strike < putWallStrike && b.strike !== pTrans); + const p3Candidate = sortedByPutGex.find( + (b) => b.strike < (p2Candidate?.strike ?? putWallStrike) && b.strike !== p2Candidate?.strike + ); + + // Find nearest expiry and DTE + const expiryDates = [...new Set(strikes.filter((s) => s.expirDate).map((s) => s.expirDate!))]; + let nearestExpiry = ""; + let nearestDte = 0; + for (const d of expiryDates) { + const expDate = new Date(d); + const dte = Math.max(0, Math.floor((expDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))); + if (!nearestExpiry || dte < nearestDte) { + nearestExpiry = d.slice(0, 10); + nearestDte = dte; + } + } + + // Regime classification (same logic as Tanuki client) + const regime = this.classifyRegime(spot, callWallStrike, hvl, cTrans, summary.netGex); + + // Build 5 key levels + const levels: TanukiLevels["levels"] = []; + const levelDefs = [ + { label: "C1", role: "Key Resistance", strike: callWallStrike }, + { 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: putWallStrike }, + ]; + for (const ld of levelDefs) { + if (ld.strike > 0) { + levels.push({ role: ld.role, strike: round2(ld.strike), label: ld.label }); + } + } + + // Extended levels + const extendedLevels: TanukiLevels["extendedLevels"] = [ + { label: "C2", strike: c2Candidate ? round2(c2Candidate.strike) : undefined }, + { label: "C3", strike: c3Candidate ? round2(c3Candidate.strike) : undefined }, + { label: "P2", strike: p2Candidate ? round2(p2Candidate.strike) : undefined }, + { label: "P3", strike: p3Candidate ? round2(p3Candidate.strike) : undefined }, + ]; + + // Compute net DEX from strikes (delta * OI * 100 per contract) + let netDex = 0; + for (const s of strikes.filter((x) => x.dte <= 7)) { + netDex += s.delta * s.callOpenInterest * 100; // call delta positive + netDex += (s.delta - 1) * s.putOpenInterest * 100; // put delta = s.delta - 1 (negative) + } + + return { + symbol: ticker, + spotPrice: spot, + expiry: nearestExpiry, + expiryDte: nearestDte, + regime, + updatedAt: new Date().toISOString(), + levels, + extendedLevels, + netGex: Math.round(summary.netGex), + netDex: Math.round(netDex), + }; + } + + // ── Regime Classification (matches Tanuki client logic) ─────────── + private classifyRegime(spot: number, c1: number, hvl: number, ctrans: number, netGex: number): TanukiLevels["regime"] { + 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"; + } + // ── Shared GEX Metrics ──────────────────────────────────────────── private computeGexMetrics(strikes: OratsStrike[], spot: number) { let callWall = spot; diff --git a/server/routes.ts b/server/routes.ts index a18496a..45fc5c9 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -14,17 +14,50 @@ import { computeSummary as mockSummary, } from "./marketData"; import { oratsClient } from "./oratsClient"; +import { tanukiClient } from "./tanukiClient"; import { registerAuthRoutes } from "./authRoutes"; -import { initDb } from "./db"; +import { initDb, getAllCustomSymbols, createCustomSymbol, deleteCustomSymbol } from "./db"; import { rateLimit, securityHeaders } from "./middleware"; const SessionMemoryStore = new MemoryStore({ checkPeriod: 86400000 }); -const KNOWN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker)); +// 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 | 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 { + 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 (!KNOWN_TICKERS.has(raw)) { + if (!getKnownTickers().has(raw)) { res.status(404).json({ error: `Unknown symbol: ${raw}` }); return; } @@ -37,6 +70,7 @@ function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown export async function registerRoutes(httpServer: Server, app: Express): Promise { await initDb(); + await refreshSymbolCache(); // ── Security Headers ────────────────────────────────────────────── app.use(securityHeaders); @@ -73,8 +107,56 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< registerAuthRoutes(app); // Public API routes - app.get("/api/symbols", (_req, res) => { - res.json(SYMBOLS); + app.get("/api/symbols", async (_req, res) => { + await refreshSymbolCache(); + res.json(getAllSymbols()); + }); + + // Add a custom symbol + app.post("/api/symbols", async (req, res) => { + const { ticker, name, type, sector } = req.body as Record; + 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", 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 @@ -110,10 +192,87 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< 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", (req, res) => { + const { email, tvUser } = req.body as Record; + 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 = {}; + 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 (!KNOWN_TICKERS.has(ticker)) { + if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { @@ -129,7 +288,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< app.get("/api/market/:symbol/gex", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); - if (!KNOWN_TICKERS.has(ticker)) { + if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { @@ -145,7 +304,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< app.get("/api/market/:symbol/expirations", async (req, res) => { const ticker = String(req.params.symbol || "").toUpperCase(); - if (!KNOWN_TICKERS.has(ticker)) { + if (!getKnownTickers().has(ticker)) { return res.status(404).json({ error: `Unknown symbol: ${ticker}` }); } try { @@ -161,12 +320,14 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< app.get("/api/screener", async (_req, res) => { try { + await refreshSymbolCache(); + const allSymbols = getAllSymbols(); if (oratsClient.isConfigured()) { const summaries = await Promise.all( - SYMBOLS.map((s) => oratsClient.computeSummary(s.ticker)), + allSymbols.map((s) => oratsClient.computeSummary(s.ticker)), ); const screenerData = summaries.map((summary, i) => { - const symbol = SYMBOLS[i]; + const symbol = allSymbols[i]; return { ticker: symbol.ticker, name: symbol.name, @@ -195,6 +356,67 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise< } }); + // ── 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; } diff --git a/server/tanukiClient.ts b/server/tanukiClient.ts new file mode 100644 index 0000000..50e20f6 --- /dev/null +++ b/server/tanukiClient.ts @@ -0,0 +1,532 @@ +// 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, +}; diff --git a/shared/schema.ts b/shared/schema.ts index 1904041..6001572 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -23,6 +23,23 @@ export const users = pgTable("users", { export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; +// --------------------------------------------------------------------------- +// Custom Symbols - User-added tickers stored in DB +// --------------------------------------------------------------------------- + +export const customSymbols = pgTable("custom_symbols", { + id: serial("id").primaryKey(), + ticker: text("ticker").notNull().unique(), + name: text("name").notNull(), + type: text("type").default("etf").notNull(), // etf, equity, index + sector: text("sector"), + userId: text("user_id"), // null = global, otherwise tied to a user + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}); + +export type CustomSymbol = typeof customSymbols.$inferSelect; +export type NewCustomSymbol = typeof customSymbols.$inferInsert; + // --------------------------------------------------------------------------- // Symbols // --------------------------------------------------------------------------- @@ -146,3 +163,101 @@ export const screenerRowSchema = z.object({ }); export type ScreenerRow = z.infer; + +// --------------------------------------------------------------------------- +// Futures conversion: ETF/Index → E-mini futures equivalents +// --------------------------------------------------------------------------- + +// ETF/Index -> Futures mapping with conversion ratio +export const futuresConversionSchema = z.object({ + sourceTicker: z.string(), // e.g., "SPY", "QQQ", "SPX" + targetTicker: z.string(), // e.g., "ES", "NQ", "YM" + targetName: z.string(), // e.g., "E-mini S&P 500", "E-mini Nasdaq-100" + multiplier: z.number(), // multiply ETF price to get futures price +}); + +export type FuturesConversion = z.infer; + +// Known mappings (ETF/Index price → futures price) +export const FUTURES_CONVERSIONS: FuturesConversion[] = [ + { sourceTicker: "SPY", targetTicker: "ES", targetName: "E-mini S&P 500 (ES)", multiplier: 10 }, + { sourceTicker: "SPX", targetTicker: "ES", targetName: "E-mini S&P 500 (ES)", multiplier: 1 }, + { sourceTicker: "QQQ", targetTicker: "NQ", targetName: "E-mini Nasdaq-100 (NQ)", multiplier: 20 }, + { sourceTicker: "IWM", targetTicker: "YM", targetName: "E-mini DJIA (YM)", multiplier: 10 }, +]; + +// --------------------------------------------------------------------------- +// Gamma levels mapped to futures +// --------------------------------------------------------------------------- + +export const futuresLevelSchema = z.object({ + sourceTicker: z.string(), + targetTicker: z.string(), + targetName: z.string(), + sourceSpot: z.number(), + futuresSpot: z.number(), + hvl: z.number(), + callWall: z.number(), + putWall: z.number(), + sourceHvl: z.number(), + sourceCallWall: z.number(), + sourcePutWall: z.number(), + topStrikes: z.array(z.object({ + sourceStrike: z.number(), + futuresStrike: z.number(), + netGex: z.number(), + magnitude: z.number(), + })), + asOf: z.string(), +}); + +export type FuturesLevel = z.infer; + +// --------------------------------------------------------------------------- +// Tanuki Trades — 5-level gamma profile + extended levels +// --------------------------------------------------------------------------- + +export const tanukiLevelSchema = z.object({ + role: z.string(), // "Key Resistance", "Minor Resistance", "Gamma Flip", "Minor Support", "Key Support" + strike: z.number(), + label: z.string(), // "C1", "cTrans", "HVL", "pTrans", "P1" +}); + +export const tanukiExtendedLevelSchema = z.object({ + label: z.string(), // "C2", "C3", "P2", "P3" + strike: z.number().optional(), +}); + +export const tanukiRegimeSchema = z.enum([ + "Positive Gamma", + "Negative Gamma", + "Positive Extension", + "Transition", + "Negative Transition", +]); + +export const tanukiLevelsSchema = z.object({ + symbol: z.string(), + spotPrice: z.number(), + expiry: z.string(), // ISO date + expiryDte: z.number(), + regime: tanukiRegimeSchema, + updatedAt: z.string(), // ISO timestamp + levels: z.array(tanukiLevelSchema), + extendedLevels: z.array(tanukiExtendedLevelSchema), + netGex: z.number(), + netDex: z.number(), +}); + +export type TanukiLevel = z.infer; +export type TanukiExtendedLevel = z.infer; +export type TanukiRegime = z.infer; +export type TanukiLevels = z.infer; + +// Tanuki config stored server-side +export const tanukiConfigSchema = z.object({ + email: z.string().email(), + tvUser: z.string(), +}); + +export type TanukiConfig = z.infer;