Compare commits
No commits in common. "master" and "v0.1.0-alpha" have entirely different histories.
master
...
v0.1.0-alp
|
|
@ -18,6 +18,3 @@ Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Research materials
|
|
||||||
tanuki_research/
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,7 @@ import DashboardPage from "@/pages/dashboard";
|
||||||
import GammaLevelsPage from "@/pages/gamma-levels";
|
import GammaLevelsPage from "@/pages/gamma-levels";
|
||||||
import ExpiryMatrixPage from "@/pages/expiry-matrix";
|
import ExpiryMatrixPage from "@/pages/expiry-matrix";
|
||||||
import ScreenerPage from "@/pages/screener";
|
import ScreenerPage from "@/pages/screener";
|
||||||
import FuturesLevelsPage from "@/pages/futures-levels";
|
|
||||||
import TanukiTradesPage from "@/pages/tanuki-trades";
|
|
||||||
import SettingsPage from "@/pages/settings";
|
import SettingsPage from "@/pages/settings";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
|
||||||
import AccountPage from "@/pages/account";
|
import AccountPage from "@/pages/account";
|
||||||
import LoginPage from "@/pages/login";
|
import LoginPage from "@/pages/login";
|
||||||
import VerifyPage from "@/pages/verify";
|
import VerifyPage from "@/pages/verify";
|
||||||
|
|
@ -30,8 +27,6 @@ function usePageTitle(): string {
|
||||||
if (location.startsWith("/gamma")) return "Gamma Levels";
|
if (location.startsWith("/gamma")) return "Gamma Levels";
|
||||||
if (location.startsWith("/expiry")) return "Expiry Matrix";
|
if (location.startsWith("/expiry")) return "Expiry Matrix";
|
||||||
if (location.startsWith("/screener")) return "Screener";
|
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("/settings")) return "Settings & API";
|
||||||
if (location.startsWith("/account")) return "Account";
|
if (location.startsWith("/account")) return "Account";
|
||||||
if (location.startsWith("/login")) return "Sign In";
|
if (location.startsWith("/login")) return "Sign In";
|
||||||
|
|
@ -96,12 +91,6 @@ function AppShell() {
|
||||||
<Route path="/gamma" component={() => <ProtectedRoute component={GammaLevelsPage} />} />
|
<Route path="/gamma" component={() => <ProtectedRoute component={GammaLevelsPage} />} />
|
||||||
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
|
<Route path="/expiry" component={() => <ProtectedRoute component={ExpiryMatrixPage} />} />
|
||||||
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
|
<Route path="/screener" component={() => <ProtectedRoute component={ScreenerPage} />} />
|
||||||
<Route path="/futures" component={() => <ProtectedRoute component={FuturesLevelsPage} />} />
|
|
||||||
<Route path="/tanuki" component={() => <ProtectedRoute component={() => (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<TanukiTradesPage />
|
|
||||||
</ErrorBoundary>
|
|
||||||
)} />} />
|
|
||||||
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
|
<Route path="/settings" component={() => <ProtectedRoute component={SettingsPage} />} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { LayoutDashboard, Activity, CalendarRange, ListFilter, TrendingUp as TrendingUpIcon, Target, Settings2, UserCircle } from "lucide-react";
|
import { LayoutDashboard, Activity, CalendarRange, ListFilter, Settings2, UserCircle } from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|
@ -19,8 +19,6 @@ const NAV = [
|
||||||
{ title: "Gamma Levels", url: "/gamma", icon: Activity, testid: "nav-gamma" },
|
{ title: "Gamma Levels", url: "/gamma", icon: Activity, testid: "nav-gamma" },
|
||||||
{ title: "Expiry Matrix", url: "/expiry", icon: CalendarRange, testid: "nav-expiry" },
|
{ title: "Expiry Matrix", url: "/expiry", icon: CalendarRange, testid: "nav-expiry" },
|
||||||
{ title: "Screener", url: "/screener", icon: ListFilter, testid: "nav-screener" },
|
{ 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: "Settings & API", url: "/settings", icon: Settings2, testid: "nav-settings" },
|
||||||
{ title: "Account", url: "/account", icon: UserCircle, testid: "nav-account" },
|
{ title: "Account", url: "/account", icon: UserCircle, testid: "nav-account" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import { Component, ErrorInfo, ReactNode } from "react";
|
|
||||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
fallback?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
|
||||||
constructor(props: Props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error("[ErrorBoundary] Caught:", error, errorInfo.componentStack);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
if (this.props.fallback) {
|
|
||||||
return this.props.fallback;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card className="m-4">
|
|
||||||
<CardContent className="flex flex-col items-center justify-center gap-4 p-8 text-center">
|
|
||||||
<AlertTriangle className="h-10 w-10 text-destructive" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold">Something went wrong</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{this.state.error?.message || "An unexpected error occurred"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => this.setState({ hasError: false, error: null })}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3 mr-1" />
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { SymbolInfo } from "@shared/schema";
|
import type { SymbolInfo } from "@shared/schema";
|
||||||
import { useSymbol } from "@/lib/symbol-context";
|
import { useSymbol } from "@/lib/symbol-context";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -12,215 +10,38 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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, Lock } from "lucide-react";
|
|
||||||
|
|
||||||
export function SymbolSelector() {
|
export function SymbolSelector() {
|
||||||
const { symbol, setSymbol } = useSymbol();
|
const { symbol, setSymbol } = useSymbol();
|
||||||
const { user } = useAuth();
|
const { data } = useQuery<SymbolInfo[]>({ queryKey: ["/api/symbols"] });
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [newTicker, setNewTicker] = useState("");
|
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
|
||||||
const isAuthenticated = !!user;
|
|
||||||
|
|
||||||
const { data } = useQuery<SymbolInfo[]>({
|
|
||||||
queryKey: ["/api/symbols"],
|
|
||||||
enabled: isAuthenticated,
|
|
||||||
});
|
|
||||||
const symbols = data ?? [];
|
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),
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
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", credentials: "include" });
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<Select value={symbol} onValueChange={setSymbol}>
|
||||||
<Select value={symbol} onValueChange={setSymbol}>
|
<SelectTrigger
|
||||||
<SelectTrigger
|
className="h-9 w-[160px] font-mono text-sm"
|
||||||
className="h-9 w-[200px] font-mono text-sm"
|
aria-label="Active symbol"
|
||||||
aria-label="Active symbol"
|
data-testid="select-symbol"
|
||||||
data-testid="select-symbol"
|
>
|
||||||
>
|
<SelectValue placeholder="Symbol" />
|
||||||
<SelectValue placeholder="Symbol" />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectGroup>
|
||||||
<SelectGroup>
|
<SelectLabel>Symbols</SelectLabel>
|
||||||
<SelectLabel>Default Symbols</SelectLabel>
|
{symbols.map((s) => (
|
||||||
{builtInSymbols.map((s) => (
|
<SelectItem
|
||||||
<SelectItem
|
key={s.ticker}
|
||||||
key={s.ticker}
|
value={s.ticker}
|
||||||
value={s.ticker}
|
data-testid={`option-symbol-${s.ticker}`}
|
||||||
data-testid={`option-symbol-${s.ticker}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="font-mono font-medium">{s.ticker}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{s.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
{customSymbols.length > 0 && (
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Custom</SelectLabel>
|
|
||||||
{customSymbols.map((s) => (
|
|
||||||
<SelectItem
|
|
||||||
key={s.ticker}
|
|
||||||
value={s.ticker}
|
|
||||||
data-testid={`option-symbol-${s.ticker}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between w-full gap-2">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="font-mono font-medium">{s.ticker}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{s.name}</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px]">custom</Badge>
|
|
||||||
</div>
|
|
||||||
{isAuthenticated ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5 -mr-2 hover:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
deleteMutation.mutate(s.ticker);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Lock className="h-3 w-3 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Add Symbol Button / Form */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{!showAdd ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1"
|
|
||||||
onClick={() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
window.location.hash = "/login";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowAdd(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
Add Symbol
|
|
||||||
{!isAuthenticated && <Lock className="h-3 w-3 text-muted-foreground" />}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1 w-full">
|
|
||||||
<Input
|
|
||||||
placeholder="TICKER"
|
|
||||||
value={newTicker}
|
|
||||||
onChange={(e) => 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("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Name"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => 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("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={addMutation.isPending || !newTicker.trim() || !newName.trim()}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<div className="flex items-baseline gap-2">
|
||||||
</Button>
|
<span className="font-mono font-medium">{s.ticker}</span>
|
||||||
<Button
|
<span className="truncate text-xs text-muted-foreground">{s.name}</span>
|
||||||
variant="ghost"
|
</div>
|
||||||
size="icon"
|
</SelectItem>
|
||||||
className="h-7 w-7"
|
))}
|
||||||
onClick={() => {
|
</SelectGroup>
|
||||||
setShowAdd(false);
|
</SelectContent>
|
||||||
setNewTicker("");
|
</Select>
|
||||||
setNewName("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{addMutation.isPending && (
|
|
||||||
<span className="text-xs text-muted-foreground">Adding...</span>
|
|
||||||
)}
|
|
||||||
{addMutation.error && (
|
|
||||||
<span className="text-xs text-destructive">{addMutation.error.message}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/auth/me", { credentials: "include" })
|
fetch("/api/auth/me")
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.ok) return r.json();
|
if (r.ok) return r.json();
|
||||||
throw new Error("not authenticated");
|
throw new Error("not authenticated");
|
||||||
|
|
@ -49,7 +49,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Authentication failed" }));
|
const err = await res.json().catch(() => ({ error: "Authentication failed" }));
|
||||||
|
|
@ -58,9 +57,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
const data: User = await res.json();
|
const data: User = await res.json();
|
||||||
setUser(data);
|
setUser(data);
|
||||||
// Full page reload to ensure auth state is established before ProtectedRoute runs
|
// Use full page navigation to dashboard - bypasses hash router issues
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
window.location.hash = "/";
|
||||||
window.location.href = "/#/";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (name: string, email: string, password: string) => {
|
const register = async (name: string, email: string, password: string) => {
|
||||||
|
|
@ -69,7 +67,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, email, password }),
|
body: JSON.stringify({ name, email, password }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Registration failed" }));
|
const err = await res.json().catch(() => ({ error: "Registration failed" }));
|
||||||
|
|
@ -82,7 +79,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setLocation("/login");
|
setLocation("/login");
|
||||||
};
|
};
|
||||||
|
|
@ -93,7 +90,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Update failed" }));
|
const err = await res.json().catch(() => ({ error: "Update failed" }));
|
||||||
|
|
@ -111,7 +107,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
|
body: JSON.stringify({ currentPassword: current, newPassword: newPass }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Password change failed" }));
|
const err = await res.json().catch(() => ({ error: "Password change failed" }));
|
||||||
|
|
@ -128,7 +123,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
||||||
|
|
@ -145,7 +139,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: "Failed to resend" }));
|
const err = await res.json().catch(() => ({ error: "Failed to resend" }));
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ export async function apiRequest(
|
||||||
method,
|
method,
|
||||||
headers: data ? { "Content-Type": "application/json" } : {},
|
headers: data ? { "Content-Type": "application/json" } : {},
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await throwIfResNotOk(res);
|
await throwIfResNotOk(res);
|
||||||
|
|
@ -31,9 +30,7 @@ export const getQueryFn: <T>(options: {
|
||||||
}) => QueryFunction<T> =
|
}) => QueryFunction<T> =
|
||||||
({ on401: unauthorizedBehavior }) =>
|
({ on401: unauthorizedBehavior }) =>
|
||||||
async ({ queryKey }) => {
|
async ({ queryKey }) => {
|
||||||
const res = await fetch(`${API_BASE}${queryKey.join("/")}`, {
|
const res = await fetch(`${API_BASE}${queryKey.join("/")}`);
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
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<FuturesLevels>({
|
|
||||||
queryKey: ["/api/futures", symbol, "levels"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = levels.data;
|
|
||||||
const isLoading = levels.isLoading;
|
|
||||||
const error = levels.error;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold tracking-tight">
|
|
||||||
Futures Gamma Levels
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Gamma-derived levels from {symbol} options mapped to futures
|
|
||||||
equivalents using ORATS data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{data && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Badge variant="outline" className="font-mono font-bold">
|
|
||||||
{symbol}
|
|
||||||
</Badge>
|
|
||||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Badge className="font-mono font-bold bg-primary/20 text-primary">
|
|
||||||
{data.targetTicker}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
{data.targetName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && (
|
|
||||||
<Card className="border-red-500/20 bg-red-500/5">
|
|
||||||
<CardContent className="py-8 text-center">
|
|
||||||
<p className="text-sm text-red-400">
|
|
||||||
{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."}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Key Levels Cards */}
|
|
||||||
{isLoading && !error ? (
|
|
||||||
<LoadingCards count={4} />
|
|
||||||
) : data ? (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<FuturesMetricCard
|
|
||||||
label={`Source Spot (${symbol})`}
|
|
||||||
value={fmtCurrency(data.sourceSpot)}
|
|
||||||
futuresValue={fmtCurrency(data.futuresSpot)}
|
|
||||||
sublabel={`${data.targetTicker} equivalent`}
|
|
||||||
/>
|
|
||||||
<FuturesMetricCard
|
|
||||||
label="HVL / Gamma Flip"
|
|
||||||
value={fmtCurrency(data.sourceHvl)}
|
|
||||||
futuresValue={fmtCurrency(data.hvl)}
|
|
||||||
sublabel="High Volume Level"
|
|
||||||
accent="hvl"
|
|
||||||
/>
|
|
||||||
<FuturesMetricCard
|
|
||||||
label="Call Wall"
|
|
||||||
value={fmtCurrency(data.sourceCallWall)}
|
|
||||||
futuresValue={fmtCurrency(data.callWall)}
|
|
||||||
sublabel="Max call gamma"
|
|
||||||
accent="call-wall"
|
|
||||||
/>
|
|
||||||
<FuturesMetricCard
|
|
||||||
label="Put Wall"
|
|
||||||
value={fmtCurrency(data.sourcePutWall)}
|
|
||||||
futuresValue={fmtCurrency(data.putWall)}
|
|
||||||
sublabel="Max put gamma"
|
|
||||||
accent="put-wall"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Levels Summary */}
|
|
||||||
{data && (
|
|
||||||
<Card className="border-border/50 bg-card/50 backdrop-blur-sm">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-semibold">
|
|
||||||
Key Levels on {data.targetTicker} Futures
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Gamma-derived levels from {symbol} options market, converted to{" "}
|
|
||||||
{data.targetTicker} futures prices. Use these as reference levels
|
|
||||||
on your futures charts.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<LevelDetail
|
|
||||||
label="Put Wall (Support)"
|
|
||||||
value={data.putWall}
|
|
||||||
spot={data.futuresSpot}
|
|
||||||
color="put-wall"
|
|
||||||
icon={<TrendingDown className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
<LevelDetail
|
|
||||||
label="HVL (Gamma Pin)"
|
|
||||||
value={data.hvl}
|
|
||||||
spot={data.futuresSpot}
|
|
||||||
color="hvl"
|
|
||||||
icon={<span className="h-4 w-4 rounded-full bg-hvl/60" />}
|
|
||||||
/>
|
|
||||||
<LevelDetail
|
|
||||||
label="Call Wall (Resistance)"
|
|
||||||
value={data.callWall}
|
|
||||||
spot={data.futuresSpot}
|
|
||||||
color="call-wall"
|
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GEX Distribution Chart (futures scale) */}
|
|
||||||
{data && data.topStrikes.length > 0 && (
|
|
||||||
<Card className="border-border/50 bg-card/50 backdrop-blur-sm">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-semibold">
|
|
||||||
Gamma Distribution ({data.targetTicker} strikes)
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Top {data.topStrikes.length} strikes ranked by absolute net
|
|
||||||
gamma, shown at {data.targetTicker} futures prices.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FuturesGexChart strikes={data.topStrikes} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Strikes Table */}
|
|
||||||
{data && data.topStrikes.length > 0 && (
|
|
||||||
<Card className="border-border/50 bg-card/50 backdrop-blur-sm">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm font-semibold">
|
|
||||||
Top Gamma Strikes ({data.targetTicker})
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Both source ({symbol}) and futures ({data.targetTicker}) prices.
|
|
||||||
Positive net GEX = dealer long gamma (stabilizing). Negative =
|
|
||||||
dealer short gamma (accelerating).
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-0 sm:px-2">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="sticky top-0 bg-card">
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[120px]">
|
|
||||||
{symbol} Strike
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[120px]">
|
|
||||||
{data.targetTicker} Strike
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">Net GEX</TableHead>
|
|
||||||
<TableHead className="text-right">Distance</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{data.topStrikes.map((row) => {
|
|
||||||
const dist =
|
|
||||||
((row.futuresStrike - data.futuresSpot) /
|
|
||||||
data.futuresSpot) *
|
|
||||||
100;
|
|
||||||
return (
|
|
||||||
<TableRow key={`${row.sourceStrike}-${row.futuresStrike}`}>
|
|
||||||
<TableCell className="num font-mono font-medium">
|
|
||||||
{fmtCurrency(row.sourceStrike)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="num font-mono font-bold text-primary">
|
|
||||||
{fmtCurrency(row.futuresStrike)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
className={cn(
|
|
||||||
"num text-right font-medium",
|
|
||||||
row.netGex >= 0
|
|
||||||
? "text-pos"
|
|
||||||
: "text-neg",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fmtCompactCurrency(row.netGex)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="num text-right text-muted-foreground">
|
|
||||||
{fmtPct(dist)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer note */}
|
|
||||||
{data && (
|
|
||||||
<div className="mt-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
|
||||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
|
||||||
<strong>How to use:</strong> 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.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sub-components ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FuturesMetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
futuresValue,
|
|
||||||
sublabel,
|
|
||||||
accent,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
futuresValue: string;
|
|
||||||
sublabel: string;
|
|
||||||
accent?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"border-border/50 bg-card/50 backdrop-blur-sm",
|
|
||||||
accent === "hvl" && "border-hvl/30",
|
|
||||||
accent === "call-wall" && "border-call-wall/30",
|
|
||||||
accent === "put-wall" && "border-put-wall/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="pt-4">
|
|
||||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex items-baseline gap-2">
|
|
||||||
<span className="num text-xs text-muted-foreground">{value}</span>
|
|
||||||
<ArrowRight className="h-3 w-3 text-muted-foreground/40" />
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"num text-lg font-bold",
|
|
||||||
accent === "hvl" && "text-hvl",
|
|
||||||
accent === "call-wall" && "text-call-wall",
|
|
||||||
accent === "put-wall" && "text-put-wall",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{futuresValue}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{sublabel}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="flex flex-col rounded-lg border border-border/40 bg-muted/20 p-3">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"mt-1 num text-2xl font-bold",
|
|
||||||
color === "hvl" && "text-hvl",
|
|
||||||
color === "call-wall" && "text-call-wall",
|
|
||||||
color === "put-wall" && "text-put-wall",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fmtCurrency(value)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{fmtPct(dist)}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div style={{ width: "100%", height: 360 }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart
|
|
||||||
data={data}
|
|
||||||
margin={{ top: 16, right: 24, bottom: 16, left: 8 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid stroke="hsl(var(--border) / 0.5)" vertical={false} />
|
|
||||||
<XAxis
|
|
||||||
type="number"
|
|
||||||
dataKey="strike"
|
|
||||||
domain={["dataMin", "dataMax"]}
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 11 }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: "hsl(var(--border) / 0.5)" }}
|
|
||||||
tickFormatter={(v) => fmtCurrency(v)}
|
|
||||||
minTickGap={24}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 11 }}
|
|
||||||
tickLine={false}
|
|
||||||
axisLine={{ stroke: "hsl(var(--border) / 0.5)" }}
|
|
||||||
tickFormatter={(v) => fmtCompactCurrency(v as number)}
|
|
||||||
width={68}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={FuturesTooltip}
|
|
||||||
cursor={{ fill: "hsl(var(--foreground) / 0.06)" }}
|
|
||||||
/>
|
|
||||||
<ReferenceLine y={0} stroke="hsl(var(--border))" strokeWidth={1} />
|
|
||||||
<Bar
|
|
||||||
dataKey="netGex"
|
|
||||||
fill="hsl(var(--pos))"
|
|
||||||
fillOpacity={0.85}
|
|
||||||
radius={[2, 2, 0, 0]}
|
|
||||||
shape={(props: any) => {
|
|
||||||
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 <rect x={x} y={yPos} width={width} height={Math.abs(h)} fill={color} fillOpacity={0.85} rx={2} ry={2} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FuturesTooltip({ active, payload }: any) {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
const row = payload[0]?.payload;
|
|
||||||
if (!row) return null;
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border bg-popover/95 px-3 py-2 text-xs shadow-md backdrop-blur">
|
|
||||||
<div className="mb-1 font-mono font-semibold">
|
|
||||||
Strike {fmtCurrency(row.strike)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-[80px_1fr] gap-x-2 num">
|
|
||||||
<span
|
|
||||||
className={row.netGex >= 0 ? "text-pos" : "text-neg"}
|
|
||||||
>
|
|
||||||
Net GEX
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={row.netGex >= 0 ? "text-pos font-medium" : "text-neg font-medium"}
|
|
||||||
>
|
|
||||||
{fmtCompactCurrency(row.netGex)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -24,10 +24,9 @@ export default function SettingsPage() {
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (key: string) => {
|
mutationFn: async (key: string) => {
|
||||||
const res = await fetch("/api/orats/config", {
|
const res = await fetch("/api/orats/config", {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ apiKey: key, baseUrl }),
|
body: JSON.stringify({ apiKey: key, baseUrl }),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to save API key");
|
if (!res.ok) throw new Error("Failed to save API key");
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
@ -41,7 +40,7 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
const clearMutation = useMutation({
|
const clearMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await fetch("/api/orats/config", { method: "DELETE", credentials: "include" });
|
const res = await fetch("/api/orats/config", { method: "DELETE" });
|
||||||
if (!res.ok) throw new Error("Failed to clear API key");
|
if (!res.ok) throw new Error("Failed to clear API key");
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,532 +0,0 @@
|
||||||
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<string, string> = {
|
|
||||||
"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<string, string> = {
|
|
||||||
"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<string, string> = {
|
|
||||||
"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<string[]>({
|
|
||||||
queryKey: ["/api/symbols/tickers"],
|
|
||||||
queryFn: () => fetch("/api/symbols", { credentials: "include" }).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<TanukiLevels>({
|
|
||||||
queryKey: ["/api/tanuki", selectedSymbol],
|
|
||||||
queryFn: async () => {
|
|
||||||
const r = await fetch(`/api/tanuki/${selectedSymbol}`, { credentials: "include" });
|
|
||||||
if (!r.ok) {
|
|
||||||
if (r.status === 404) {
|
|
||||||
return await r.json();
|
|
||||||
}
|
|
||||||
throw new Error(`Tanuki fetch failed: ${r.status}`);
|
|
||||||
}
|
|
||||||
return r.json();
|
|
||||||
},
|
|
||||||
enabled: !compareMode && !!selectedSymbol,
|
|
||||||
staleTime: 2 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Multi-symbol comparison query — uses all symbols
|
|
||||||
const compareQuery = useQuery<Record<string, TanukiLevels | { error: string }>>({
|
|
||||||
queryKey: ["/api/tanuki", "compare"],
|
|
||||||
queryFn: () => fetch(`/api/tanuki?symbols=${allSymbols.join(",")}`, { credentials: "include" }).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", { credentials: "include" }).then(r => r.json()),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const tanukiConfigured = statusQuery.data?.configured;
|
|
||||||
const oratsStatusQuery = useQuery({
|
|
||||||
queryKey: ["/api/orats/status"],
|
|
||||||
queryFn: () => fetch("/api/orats/status", { credentials: "include" }).then(r => r.json()),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
const oratsConfigured = oratsStatusQuery.data?.configured;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h2 className="text-base font-semibold tracking-tight">Gamma Levels</h2>
|
|
||||||
<Badge variant="outline" className="text-[10px] border-border/50 bg-muted/50">
|
|
||||||
Tanuki-style
|
|
||||||
</Badge>
|
|
||||||
{!tanukiConfigured && oratsConfigured && (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-emerald-400/30 bg-emerald-400/10 text-emerald-400">
|
|
||||||
Powered by ORATS
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!tanukiConfigured && !oratsConfigured && (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-rose-400/30 bg-rose-400/10 text-rose-400">
|
|
||||||
No Data Source
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground max-w-xl">
|
|
||||||
5-level gamma exposure profiles with regime classification and extended support/resistance zones.
|
|
||||||
{tanukiConfigured ? " Data from Tanuki Trades." : " Derived from ORATS options data."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!compareMode && (
|
|
||||||
<Select value={selectedSymbol} onValueChange={setSelectedSymbol}>
|
|
||||||
<SelectTrigger className="w-[120px] h-8 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{allSymbols.map((s) => (
|
|
||||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant={compareMode ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
onClick={() => setCompareMode(!compareMode)}
|
|
||||||
>
|
|
||||||
{compareMode ? "Single View" : "Compare All"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comparison view */}
|
|
||||||
{compareMode ? (
|
|
||||||
<CompareView data={compareQuery.data} loading={compareQuery.isLoading} />
|
|
||||||
) : (
|
|
||||||
/* Single symbol view */
|
|
||||||
<SingleView
|
|
||||||
data={tanukiQuery.data}
|
|
||||||
loading={tanukiQuery.isLoading}
|
|
||||||
error={tanukiQuery.error}
|
|
||||||
symbol={selectedSymbol}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xs font-semibold">Level Legend</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
||||||
{[
|
|
||||||
{ 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) => (
|
|
||||||
<div key={item.label} className="flex items-start gap-2 rounded-lg border border-border/50 bg-muted/30 p-2.5">
|
|
||||||
<div className={cn("h-2.5 w-2.5 rounded-full mt-0.5 shrink-0", item.color)} />
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-medium">{item.label}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-0.5">{item.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Single Symbol View
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function SingleView({
|
|
||||||
data,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
symbol,
|
|
||||||
}: {
|
|
||||||
data: TanukiLevels | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
symbol: string;
|
|
||||||
}) {
|
|
||||||
if (loading) return <LoadingBlock height={500} />;
|
|
||||||
if (error) return <ErrorState error={error.message} symbol={symbol} />;
|
|
||||||
if (!data) return <ErrorState error="No data available" symbol={symbol} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-5 lg:grid-cols-3">
|
|
||||||
{/* Left: Level visualization */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle className="text-sm font-semibold">5-Level Profile — {data.symbol}</CardTitle>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
|
||||||
Spot: {data.spotPrice.toFixed(2)} · Expiry: {data.expiry} ({data.expiryDte} DTE)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RegimeBadge regime={data.regime} />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<LevelLadder levels={data.levels} spot={data.spotPrice} extended={data.extendedLevels} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Right: Metrics */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{/* GEX Metrics */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xs font-semibold flex items-center gap-2">
|
|
||||||
<Zap className="h-3 w-3" /> Gamma Metrics
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<MetricRow
|
|
||||||
label="Net GEX"
|
|
||||||
value={fmtCompactCurrency(data.netGex)}
|
|
||||||
sub={data.netGex >= 0 ? "Positive gamma — mean reverting" : "Negative gamma — trend amplifying"}
|
|
||||||
positive={data.netGex >= 0}
|
|
||||||
/>
|
|
||||||
<MetricRow
|
|
||||||
label="Net DEX"
|
|
||||||
value={fmtCompactCurrency(data.netDex)}
|
|
||||||
sub="Delta exposure"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Extended Levels */}
|
|
||||||
{data.extendedLevels.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xs font-semibold">Extended Levels</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.extendedLevels.map((el) => (
|
|
||||||
<div key={el.label} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="font-medium text-muted-foreground">{el.label}</span>
|
|
||||||
<span className="font-mono">{el.strike ? el.strike.toFixed(2) : "—"}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp */}
|
|
||||||
<div className="text-[10px] text-muted-foreground text-center pt-2">
|
|
||||||
Updated: {new Date(data.updatedAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Compare View
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function CompareView({
|
|
||||||
data,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
data: Record<string, TanukiLevels | { error: string }> | undefined;
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
|
||||||
if (loading) return <LoadingBlock height={400} />;
|
|
||||||
if (!data) return <ErrorState error="No comparison data" symbol="" />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-semibold">Multi-Symbol Comparison</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border/50">
|
|
||||||
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Symbol</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">Spot</th>
|
|
||||||
<th className="text-center py-2 px-3 font-medium text-muted-foreground">Regime</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">C1</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">cTrans</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">HVL</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">pTrans</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">P1</th>
|
|
||||||
<th className="text-right py-2 px-3 font-medium text-muted-foreground">Net GEX</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{Object.entries(data).map(([sym, result]) => (
|
|
||||||
<tr key={sym} className="border-b border-border/30 hover:bg-muted/30">
|
|
||||||
<td className="py-2 px-3 font-semibold">{sym}</td>
|
|
||||||
{"error" in result ? (
|
|
||||||
<td colSpan={8} className="py-2 px-3 text-rose-400">{result.error}</td>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="py-2 px-3 text-right font-mono">{result.spotPrice.toFixed(2)}</td>
|
|
||||||
<td className="py-2 px-3 text-center"><RegimeBadge regime={result.regime} /></td>
|
|
||||||
{result.levels.map((l) => (
|
|
||||||
<td key={l.label} className="py-2 px-3 text-right font-mono">
|
|
||||||
{l.strike.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className={cn(
|
|
||||||
"py-2 px-3 text-right font-mono",
|
|
||||||
result.netGex >= 0 ? "text-emerald-400" : "text-rose-400"
|
|
||||||
)}>
|
|
||||||
{fmtCompactCurrency(result.netGex)}
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 (
|
|
||||||
<div className="relative" style={{ minHeight: 320 }}>
|
|
||||||
{/* Spot line */}
|
|
||||||
<div
|
|
||||||
className="absolute left-0 right-0 z-10 flex items-center"
|
|
||||||
style={{ top: `${pctPosition(spot)}%`, transform: "translateY(-50%)" }}
|
|
||||||
>
|
|
||||||
<div className="w-full border-t-2 border-dashed border-primary/40" />
|
|
||||||
<span className="absolute -right-1 top-1/2 -translate-y-1/2 rounded bg-primary px-1.5 py-0.5 text-[10px] font-bold text-primary-foreground">
|
|
||||||
SPOT {spot.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Levels */}
|
|
||||||
{sorted.map((level) => {
|
|
||||||
const distPct = ((level.strike - spot) / spot) * 100;
|
|
||||||
const colorClass = LEVEL_COLORS[level.role] || "text-muted-foreground";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={level.label}
|
|
||||||
className="absolute left-0 right-0 flex items-center transition-all"
|
|
||||||
style={{ top: `${pctPosition(level.strike)}%`, transform: "translateY(-50%)" }}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center gap-2">
|
|
||||||
{/* Label */}
|
|
||||||
<div className="w-16 shrink-0 text-right">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground">{level.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Line */}
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<div className={cn(
|
|
||||||
"absolute h-0.5 w-full transition-all",
|
|
||||||
level.role.includes("Resistance") ? "bg-red-400" :
|
|
||||||
level.role === "Gamma Flip" ? "bg-yellow-400" : "bg-emerald-400"
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Strike */}
|
|
||||||
<div className={cn("w-24 shrink-0 rounded px-2 py-1", colorClass)}>
|
|
||||||
<span className="font-mono text-xs font-medium">{level.strike.toFixed(2)}</span>
|
|
||||||
<span className="ml-1.5 text-[10px] opacity-70">{fmtPct(distPct)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Role */}
|
|
||||||
<div className="w-32 shrink-0">
|
|
||||||
<span className="text-[10px] text-muted-foreground">{level.role}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Extended levels (subtle) */}
|
|
||||||
{extended.filter(e => e.strike).map((el) => (
|
|
||||||
<div
|
|
||||||
key={el.label}
|
|
||||||
className="absolute left-0 right-0 flex items-center"
|
|
||||||
style={{ top: `${pctPosition(el.strike!)}%`, transform: "translateY(-50%)" }}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center gap-2 opacity-50">
|
|
||||||
<div className="w-16 shrink-0 text-right">
|
|
||||||
<span className="text-[10px] font-bold text-muted-foreground">{el.label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<div className="absolute h-px w-full border-t border-dashed border-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<div className="w-24 shrink-0">
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">{el.strike!.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-32 shrink-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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 (
|
|
||||||
<Badge variant="outline" className={cn("gap-1.5 px-2 py-0.5 text-[11px]", colors)}>
|
|
||||||
<Icon className="h-3 w-3" />
|
|
||||||
{regime}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Metric Row
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function MetricRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
sub,
|
|
||||||
positive,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
sub?: string;
|
|
||||||
positive?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<span className="text-[11px] text-muted-foreground">{label}</span>
|
|
||||||
<span className={cn(
|
|
||||||
"font-mono text-sm font-medium",
|
|
||||||
positive === true && "text-emerald-400",
|
|
||||||
positive === false && "text-rose-400"
|
|
||||||
)}>{value}</span>
|
|
||||||
</div>
|
|
||||||
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Error State
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function ErrorState({ error, symbol }: { error: string; symbol: string }) {
|
|
||||||
return (
|
|
||||||
<Card className="border-rose-400/20">
|
|
||||||
<CardContent className="py-8">
|
|
||||||
<div className="flex flex-col items-center gap-3 text-center">
|
|
||||||
<Shield className="h-8 w-8 text-rose-400/50" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-rose-400">Failed to load {symbol} data</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{error}</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground mt-2 max-w-sm">
|
|
||||||
Tanuki Trades requires authentication via email + TradingView username.
|
|
||||||
Configure in Settings & API, or check that the session is valid.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,7 @@ export default function VerifyPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`/api/auth/verify/${token}`, { credentials: "include" })
|
fetch(`/api/auth/verify/${token}`)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-redis": "^9.0.0",
|
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|
@ -72,7 +71,6 @@
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"redis": "^6.0.1",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
|
@ -3136,78 +3134,6 @@
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redis/bloom": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-6ys5hhea+47n7o97ZFI4GvdzTQk/arIsXZgH159l6IVtJ4rZaB+KVdAfwvIxlmGA7z+NNlO8UxjTeQrenqjZcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/client": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-SwYl64hKHE/NeO2VSSG1y/4zIm0cNepyOZtQrOpLiNRHmH2FdWBOecNzsLiXCQdFCF9MCyoPXwAbaG2iMO0A7Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cluster-key-slot": "1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@node-rs/xxhash": "^1.1.0",
|
|
||||||
"@opentelemetry/api": ">=1 <2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@node-rs/xxhash": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@opentelemetry/api": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/json": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-KD1OztCYh7O9TkKMU9qZcFIKoudIGqmgXsOhQVq5A3REGrnl+wg0kporQFQCO+fcxe/nhvDgmBtXrm3diPGczA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/search": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-G09OujS3eOtQnP7kZC5eZTiazwgeimlo6Pf3vHnE1jO7rfqrtmMI0R1/ZXfzoW8p9vB4QiH538aEsWaHKd8l5w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@redis/time-series": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-8aLGSDtCpnPTLD7lEiHHmuDCFppctLdT8geFIDf/7LWV9y8Vre6RB+aBZrgkeo3X1oPmTt1IbVAQVxsuJvkODw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^6.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -4735,15 +4661,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cluster-key-slot": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cmdk": {
|
"node_modules/cmdk": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||||
|
|
@ -4769,19 +4686,6 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/connect-redis": {
|
|
||||||
"version": "9.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-9.0.0.tgz",
|
|
||||||
"integrity": "sha512-QwzyvUePTMvEzG1hy45gZYw3X3YHrjmEdSkayURlcZft7hqadQ3X39wYkmCqblK2rGlw+XItELYt6GnyG6DEIQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"express-session": ">=1",
|
|
||||||
"redis": ">=5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
|
|
@ -7616,22 +7520,6 @@
|
||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/redis": {
|
|
||||||
"version": "6.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-6.0.1.tgz",
|
|
||||||
"integrity": "sha512-54FoTBdFw10Y602pShvk8CGJlSH55nY+CNAZaVk8YdxY3rENihdYm2lXrujrtupYTHyrVSZUxOdeStNQbNvnQg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@redis/bloom": "6.0.1",
|
|
||||||
"@redis/client": "6.0.1",
|
|
||||||
"@redis/json": "6.0.1",
|
|
||||||
"@redis/search": "6.0.1",
|
|
||||||
"@redis/time-series": "6.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/regexparam": {
|
"node_modules/regexparam": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-redis": "^9.0.0",
|
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|
@ -74,7 +73,6 @@
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"redis": "^6.0.1",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,8 @@ export function registerAuthRoutes(app: Express) {
|
||||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
||||||
await updateUserVerification(user.id, token, expiry);
|
await updateUserVerification(user.id, token, expiry);
|
||||||
|
|
||||||
// Send verification email (fire-and-forget — don't block registration on SMTP failure)
|
// Send verification email
|
||||||
sendVerificationEmail(sanitizedEmail, sanitizedName, token).catch((err) => {
|
await sendVerificationEmail(sanitizedEmail, sanitizedName, token);
|
||||||
console.error("[Auth] Verification email failed (non-blocking):", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|
@ -165,10 +163,7 @@ export function registerAuthRoutes(app: Express) {
|
||||||
const token = crypto.randomUUID();
|
const token = crypto.randomUUID();
|
||||||
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
const expiry = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
await updateUserVerification(user.id, token, expiry);
|
await updateUserVerification(user.id, token, expiry);
|
||||||
// Fire-and-forget — don't block response on SMTP failure
|
await sendVerificationEmail(user.email, user.name, token);
|
||||||
sendVerificationEmail(user.email, user.name, token).catch((err) => {
|
|
||||||
console.error("[Auth] Resend verification email failed (non-blocking):", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ ok: true, message: "Verification email sent. Please check your inbox." });
|
res.json({ ok: true, message: "Verification email sent. Please check your inbox." });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
70
server/db.ts
70
server/db.ts
|
|
@ -1,6 +1,6 @@
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { users, customSymbols, User, NewUser, CustomSymbol, NewCustomSymbol } from "../shared/schema";
|
import { users, User, NewUser } from "../shared/schema";
|
||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
|
|
||||||
// PostgreSQL connection (local on production droplet)
|
// PostgreSQL connection (local on production droplet)
|
||||||
|
|
@ -18,22 +18,9 @@ const pool = new Pool({
|
||||||
|
|
||||||
export const db = drizzle(pool);
|
export const db = drizzle(pool);
|
||||||
|
|
||||||
// Use raw pool client for DDL — Drizzle wraps queries in prepared statements
|
|
||||||
// and PostgreSQL rejects CREATE/ALTER IF NOT EXISTS in prepared queries.
|
|
||||||
async function executeDDL(query: string): Promise<void> {
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query(query);
|
|
||||||
} catch {
|
|
||||||
// Table/column already exists — ignore silently
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize users table if it doesn't exist (called at startup)
|
// Initialize users table if it doesn't exist (called at startup)
|
||||||
export async function initDb(): Promise<void> {
|
export async function initDb(): Promise<void> {
|
||||||
await executeDDL(`
|
await db.execute(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|
@ -45,24 +32,19 @@ export async function initDb(): Promise<void> {
|
||||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add columns if they don't exist (for existing tables)
|
// Add columns if they don't exist (for existing tables)
|
||||||
await executeDDL(`ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE`);
|
await addColumnIfExists('email_verified', 'BOOLEAN NOT NULL DEFAULT FALSE');
|
||||||
await executeDDL(`ALTER TABLE users ADD COLUMN verification_token TEXT`);
|
await addColumnIfExists('verification_token', 'TEXT');
|
||||||
await executeDDL(`ALTER TABLE users ADD COLUMN verification_token_expiry TIMESTAMP WITH TIME ZONE`);
|
await addColumnIfExists('verification_token_expiry', 'TIMESTAMP WITH TIME ZONE');
|
||||||
|
}
|
||||||
|
|
||||||
// Create custom_symbols table
|
async function addColumnIfExists(column: string, typeDef: string): Promise<void> {
|
||||||
await executeDDL(`
|
try {
|
||||||
CREATE TABLE IF NOT EXISTS custom_symbols (
|
await db.execute(`ALTER TABLE users ADD COLUMN ${column} ${typeDef}`);
|
||||||
id SERIAL PRIMARY KEY,
|
} catch {
|
||||||
ticker TEXT NOT NULL UNIQUE,
|
// Column already exists or table doesn't exist — ignore
|
||||||
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()
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(data: NewUser): Promise<User> {
|
export async function createUser(data: NewUser): Promise<User> {
|
||||||
|
|
@ -108,29 +90,3 @@ export async function getUserByVerificationToken(token: string): Promise<User |
|
||||||
export async function markEmailVerified(id: number): Promise<void> {
|
export async function markEmailVerified(id: number): Promise<void> {
|
||||||
await db.update(users).set({ emailVerified: true, verificationToken: null, verificationTokenExpiry: null }).where(eq(users.id, id));
|
await db.update(users).set({ emailVerified: true, verificationToken: null, verificationTokenExpiry: null }).where(eq(users.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Custom Symbols CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getAllCustomSymbols(): Promise<CustomSymbol[]> {
|
|
||||||
return await db.select().from(customSymbols);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCustomSymbolByTicker(ticker: string): Promise<CustomSymbol | undefined> {
|
|
||||||
const result = await db.select().from(customSymbols).where(eq(customSymbols.ticker, ticker.toUpperCase()));
|
|
||||||
return result[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createCustomSymbol(data: NewCustomSymbol): Promise<CustomSymbol> {
|
|
||||||
const [symbol] = await db.insert(customSymbols).values({
|
|
||||||
...data,
|
|
||||||
ticker: data.ticker?.toUpperCase() || data.ticker,
|
|
||||||
}).returning();
|
|
||||||
return symbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteCustomSymbol(ticker: string): Promise<boolean> {
|
|
||||||
const result = await db.delete(customSymbols).where(eq(customSymbols.ticker, ticker.toUpperCase())).returning();
|
|
||||||
return result.length > 0;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ declare module "http" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust reverse proxy (nginx) for protocol/headers so session cookies work over HTTPS
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
express.json({
|
express.json({
|
||||||
verify: (req, _res, buf) => {
|
verify: (req, _res, buf) => {
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,9 @@ export const SYMBOLS: SymbolInfo[] = [
|
||||||
{ ticker: "SPY", name: "SPDR S&P 500 ETF", type: "etf", sector: "Broad index" },
|
{ ticker: "SPY", name: "SPDR S&P 500 ETF", type: "etf", sector: "Broad index" },
|
||||||
{ ticker: "QQQ", name: "Invesco QQQ Trust", type: "etf", sector: "Technology" },
|
{ ticker: "QQQ", name: "Invesco QQQ Trust", type: "etf", sector: "Technology" },
|
||||||
{ ticker: "SPX", name: "S&P 500 Index", type: "index", sector: "Broad index" },
|
{ ticker: "SPX", name: "S&P 500 Index", type: "index", sector: "Broad index" },
|
||||||
{ ticker: "SPCX", name: "Invesco S&P 500 Pure Value ETF", type: "etf", sector: "Value" },
|
{ ticker: "AAPL", name: "Apple Inc.", type: "equity", sector: "Technology" },
|
||||||
{ ticker: "TSLA", name: "Tesla, Inc.", type: "equity", sector: "Consumer cyclical" },
|
{ ticker: "TSLA", name: "Tesla, Inc.", type: "equity", sector: "Consumer cyclical" },
|
||||||
{ ticker: "NVDA", name: "NVIDIA Corporation", type: "equity", sector: "Semiconductors" },
|
{ 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
|
// Reference spot prices and characteristic IV ranges per ticker. These keep
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
//
|
//
|
||||||
// Rate limits: 100 req/min per API key
|
// Rate limits: 100 req/min per API key
|
||||||
|
|
||||||
import type { Summary, GexProfile, ExpirationsResponse, TanukiLevels } from "@shared/schema";
|
import type { Summary, GexProfile, ExpirationsResponse } from "@shared/schema";
|
||||||
|
|
||||||
export interface OratsClientConfig {
|
export interface OratsClientConfig {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
|
@ -278,143 +278,6 @@ export class OratsClient {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tanuki-style levels from ORATS GEX data ───────────────────────
|
|
||||||
async computeTanukiLevels(ticker: string): Promise<TanukiLevels> {
|
|
||||||
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 ────────────────────────────────────────────
|
// ── Shared GEX Metrics ────────────────────────────────────────────
|
||||||
private computeGexMetrics(strikes: OratsStrike[], spot: number) {
|
private computeGexMetrics(strikes: OratsStrike[], spot: number) {
|
||||||
let callWall = spot;
|
let callWall = spot;
|
||||||
|
|
|
||||||
285
server/routes.ts
285
server/routes.ts
|
|
@ -1,9 +1,11 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import type { Server } from "node:http";
|
import type { Server } from "node:http";
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
import * as session from "express-session";
|
import * as session from "express-session";
|
||||||
import { RedisStore } from "connect-redis";
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
import { createClient } from "redis";
|
import MemoryStoreModule from "memorystore";
|
||||||
|
const MemoryStore = MemoryStoreModule(session.default || session);
|
||||||
import {
|
import {
|
||||||
SYMBOLS,
|
SYMBOLS,
|
||||||
computeExpirations as mockExpirations,
|
computeExpirations as mockExpirations,
|
||||||
|
|
@ -12,48 +14,17 @@ import {
|
||||||
computeSummary as mockSummary,
|
computeSummary as mockSummary,
|
||||||
} from "./marketData";
|
} from "./marketData";
|
||||||
import { oratsClient } from "./oratsClient";
|
import { oratsClient } from "./oratsClient";
|
||||||
import { tanukiClient } from "./tanukiClient";
|
import { registerAuthRoutes } from "./authRoutes";
|
||||||
import { registerAuthRoutes, requireAuth } from "./authRoutes";
|
import { initDb } from "./db";
|
||||||
import { initDb, getAllCustomSymbols, createCustomSymbol, deleteCustomSymbol } from "./db";
|
|
||||||
import { rateLimit, securityHeaders } from "./middleware";
|
import { rateLimit, securityHeaders } from "./middleware";
|
||||||
|
|
||||||
// Built-in tickers from marketData
|
const SessionMemoryStore = new MemoryStore({ checkPeriod: 86400000 });
|
||||||
const BUILTIN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker));
|
|
||||||
|
|
||||||
// Cache of all symbols (built-in + custom) — refreshed on demand
|
const KNOWN_TICKERS = new Set(SYMBOLS.map((s) => s.ticker));
|
||||||
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) {
|
function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown) {
|
||||||
const raw = String(req.params.symbol || "").toUpperCase();
|
const raw = String(req.params.symbol || "").toUpperCase();
|
||||||
if (!getKnownTickers().has(raw)) {
|
if (!KNOWN_TICKERS.has(raw)) {
|
||||||
res.status(404).json({ error: `Unknown symbol: ${raw}` });
|
res.status(404).json({ error: `Unknown symbol: ${raw}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +37,6 @@ function withTicker(req: Request, res: Response, fn: (ticker: string) => unknown
|
||||||
|
|
||||||
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
|
export async function registerRoutes(httpServer: Server, app: Express): Promise<Server> {
|
||||||
await initDb();
|
await initDb();
|
||||||
await refreshSymbolCache();
|
|
||||||
|
|
||||||
// ── Security Headers ──────────────────────────────────────────────
|
// ── Security Headers ──────────────────────────────────────────────
|
||||||
app.use(securityHeaders);
|
app.use(securityHeaders);
|
||||||
|
|
@ -79,37 +49,18 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
// Auth-specific rate limit: 50 requests per 15 minutes (relaxed for dev)
|
// Auth-specific rate limit: 50 requests per 15 minutes (relaxed for dev)
|
||||||
app.use("/api/auth/", rateLimit({ windowMs: 15 * 60 * 1000, max: 50 }));
|
app.use("/api/auth/", rateLimit({ windowMs: 15 * 60 * 1000, max: 50 }));
|
||||||
|
|
||||||
// ── Session Configuration (Redis-backed for persistence) ──────────
|
// ── Session Configuration ─────────────────────────────────────────
|
||||||
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(
|
app.use(
|
||||||
session.default?.({
|
(session.default || session)({
|
||||||
store: redisSessionStore,
|
store: new MemoryStore({ checkPeriod: 86400000 }),
|
||||||
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
|
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
|
secure: process.env.NODE_ENV === "production",
|
||||||
httpOnly: true, // Prevent XSS from accessing session cookie
|
httpOnly: true, // Prevent XSS from accessing session cookie
|
||||||
sameSite: "lax", // Allow same-site + top-level navigation
|
sameSite: "strict", // Prevent CSRF
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
},
|
|
||||||
}) || session({
|
|
||||||
store: redisSessionStore,
|
|
||||||
secret: process.env.SESSION_SECRET || "gammadesk-dev-secret-change-me",
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: false,
|
|
||||||
cookie: {
|
|
||||||
secure: process.env.NODE_ENV === "production" || (process.env.APP_URL || "").startsWith("https"),
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -122,56 +73,8 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
registerAuthRoutes(app);
|
registerAuthRoutes(app);
|
||||||
|
|
||||||
// Public API routes
|
// Public API routes
|
||||||
app.get("/api/symbols", async (_req, res) => {
|
app.get("/api/symbols", (_req, res) => {
|
||||||
await refreshSymbolCache();
|
res.json(SYMBOLS);
|
||||||
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
|
// ORATS status - hide sensitive info
|
||||||
|
|
@ -183,7 +86,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save ORATS API key + base URL to session
|
// Save ORATS API key + base URL to session
|
||||||
app.post("/api/orats/config", requireAuth, (req, res) => {
|
app.post("/api/orats/config", (req, res) => {
|
||||||
const { apiKey, baseUrl } = req.body as Record<string, string>;
|
const { apiKey, baseUrl } = req.body as Record<string, string>;
|
||||||
const session = req.session as Record<string, unknown>;
|
const session = req.session as Record<string, unknown>;
|
||||||
if (!apiKey || typeof apiKey !== "string") {
|
if (!apiKey || typeof apiKey !== "string") {
|
||||||
|
|
@ -200,94 +103,17 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear ORATS API key from session
|
// Clear ORATS API key from session
|
||||||
app.delete("/api/orats/config", requireAuth, (req, res) => {
|
app.delete("/api/orats/config", (req, res) => {
|
||||||
const session = req.session as Record<string, unknown>;
|
const session = req.session as Record<string, unknown>;
|
||||||
delete session.oratsApiKey;
|
delete session.oratsApiKey;
|
||||||
oratsClient.setApiKey("");
|
oratsClient.setApiKey("");
|
||||||
res.json({ ok: true, configured: oratsClient.isConfigured() });
|
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
|
// Market data routes
|
||||||
app.get("/api/market/:symbol/summary", async (req, res) => {
|
app.get("/api/market/:symbol/summary", async (req, res) => {
|
||||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||||
if (!getKnownTickers().has(ticker)) {
|
if (!KNOWN_TICKERS.has(ticker)) {
|
||||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -303,7 +129,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
|
|
||||||
app.get("/api/market/:symbol/gex", async (req, res) => {
|
app.get("/api/market/:symbol/gex", async (req, res) => {
|
||||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||||
if (!getKnownTickers().has(ticker)) {
|
if (!KNOWN_TICKERS.has(ticker)) {
|
||||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -319,7 +145,7 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
|
|
||||||
app.get("/api/market/:symbol/expirations", async (req, res) => {
|
app.get("/api/market/:symbol/expirations", async (req, res) => {
|
||||||
const ticker = String(req.params.symbol || "").toUpperCase();
|
const ticker = String(req.params.symbol || "").toUpperCase();
|
||||||
if (!getKnownTickers().has(ticker)) {
|
if (!KNOWN_TICKERS.has(ticker)) {
|
||||||
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
return res.status(404).json({ error: `Unknown symbol: ${ticker}` });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -335,14 +161,12 @@ export async function registerRoutes(httpServer: Server, app: Express): Promise<
|
||||||
|
|
||||||
app.get("/api/screener", async (_req, res) => {
|
app.get("/api/screener", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await refreshSymbolCache();
|
|
||||||
const allSymbols = getAllSymbols();
|
|
||||||
if (oratsClient.isConfigured()) {
|
if (oratsClient.isConfigured()) {
|
||||||
const summaries = await Promise.all(
|
const summaries = await Promise.all(
|
||||||
allSymbols.map((s) => oratsClient.computeSummary(s.ticker)),
|
SYMBOLS.map((s) => oratsClient.computeSummary(s.ticker)),
|
||||||
);
|
);
|
||||||
const screenerData = summaries.map((summary, i) => {
|
const screenerData = summaries.map((summary, i) => {
|
||||||
const symbol = allSymbols[i];
|
const symbol = SYMBOLS[i];
|
||||||
return {
|
return {
|
||||||
ticker: symbol.ticker,
|
ticker: symbol.ticker,
|
||||||
name: symbol.name,
|
name: symbol.name,
|
||||||
|
|
@ -371,67 +195,6 @@ 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;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,532 +0,0 @@
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
115
shared/schema.ts
115
shared/schema.ts
|
|
@ -23,23 +23,6 @@ export const users = pgTable("users", {
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type NewUser = typeof users.$inferInsert;
|
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
|
// Symbols
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -163,101 +146,3 @@ export const screenerRowSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ScreenerRow = z.infer<typeof screenerRowSchema>;
|
export type ScreenerRow = z.infer<typeof screenerRowSchema>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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<typeof futuresConversionSchema>;
|
|
||||||
|
|
||||||
// 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<typeof futuresLevelSchema>;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 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<typeof tanukiLevelSchema>;
|
|
||||||
export type TanukiExtendedLevel = z.infer<typeof tanukiExtendedLevelSchema>;
|
|
||||||
export type TanukiRegime = z.infer<typeof tanukiRegimeSchema>;
|
|
||||||
export type TanukiLevels = z.infer<typeof tanukiLevelsSchema>;
|
|
||||||
|
|
||||||
// Tanuki config stored server-side
|
|
||||||
export const tanukiConfigSchema = z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
tvUser: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TanukiConfig = z.infer<typeof tanukiConfigSchema>;
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
cd /home/chuck/projects/gammadesk
|
|
||||||
NODE_ENV=development npx tsx server/index.ts "$@"
|
|
||||||
Loading…
Reference in New Issue