175 lines
6.1 KiB
TypeScript
175 lines
6.1 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { GexProfile, Summary } from "@shared/schema";
|
|
import { useSymbol } from "@/lib/symbol-context";
|
|
import { fmtCompactCurrency, fmtStrike, fmtPct } from "@/lib/format";
|
|
import { GexChart } from "@/components/gex-chart";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { MetricTooltip } from "@/components/metric-tooltip";
|
|
import { LoadingBlock } from "@/components/loading-block";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export default function GammaLevelsPage() {
|
|
const { symbol } = useSymbol();
|
|
const summary = useQuery<Summary>({ queryKey: ["/api/market", symbol, "summary"] });
|
|
const gex = useQuery<GexProfile>({ queryKey: ["/api/market", symbol, "gex"] });
|
|
|
|
const s = summary.data;
|
|
const g = gex.data;
|
|
|
|
// Sort bars by absolute net gex magnitude — the top 10 are the meaningful walls.
|
|
const ranked = useMemo(() => {
|
|
if (!g) return [];
|
|
return [...g.bars]
|
|
.map((b) => ({
|
|
...b,
|
|
magnitude: Math.abs(b.netGex),
|
|
absCall: Math.abs(b.callGex),
|
|
absPut: Math.abs(b.putGex),
|
|
}))
|
|
.sort((a, b) => b.magnitude - a.magnitude)
|
|
.slice(0, 12);
|
|
}, [g]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 p-4 sm:p-6">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-base font-semibold tracking-tight">Gamma levels</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Dealer gamma by strike with the top concentration zones. Use this view to identify pin candidates and break levels.
|
|
</p>
|
|
</div>
|
|
{s && (
|
|
<div className="flex flex-wrap gap-2 text-xs">
|
|
<KeyLevel
|
|
color="hvl"
|
|
label="HVL"
|
|
value={fmtStrike(s.hvl)}
|
|
from={s.spot}
|
|
tooltipMetric="hvl"
|
|
/>
|
|
<KeyLevel
|
|
color="call-wall"
|
|
label="Call Wall"
|
|
value={fmtStrike(s.callWall)}
|
|
from={s.spot}
|
|
tooltipMetric="callWall"
|
|
/>
|
|
<KeyLevel
|
|
color="put-wall"
|
|
label="Put Wall"
|
|
value={fmtStrike(s.putWall)}
|
|
from={s.spot}
|
|
tooltipMetric="putWall"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-semibold">Gamma exposure profile</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{g ? <GexChart profile={g} height={420} /> : <LoadingBlock height={420} />}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-semibold">Top strike concentrations</CardTitle>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Strikes ranked by absolute net gamma. Positive net values indicate dealer-long gamma; negative values indicate dealer-short gamma.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent className="px-0 sm:px-2">
|
|
<div className="overflow-x-auto">
|
|
<Table data-testid="table-top-strikes">
|
|
<TableHeader className="sticky top-0 bg-card">
|
|
<TableRow>
|
|
<TableHead className="w-[110px]">Strike</TableHead>
|
|
<TableHead className="text-right">Call GEX</TableHead>
|
|
<TableHead className="text-right">Put GEX</TableHead>
|
|
<TableHead className="text-right">Net GEX</TableHead>
|
|
<TableHead className="text-right">Distance</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ranked.map((row) => {
|
|
const dist = s ? ((row.strike - s.spot) / s.spot) * 100 : 0;
|
|
return (
|
|
<TableRow key={row.strike} data-testid={`row-strike-${row.strike}`}>
|
|
<TableCell className="num font-medium">{fmtStrike(row.strike)}</TableCell>
|
|
<TableCell className="num text-right text-pos">
|
|
{fmtCompactCurrency(row.callGex)}
|
|
</TableCell>
|
|
<TableCell className="num text-right text-neg">
|
|
{fmtCompactCurrency(row.putGex)}
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function KeyLevel({
|
|
color,
|
|
label,
|
|
value,
|
|
from,
|
|
tooltipMetric,
|
|
}: {
|
|
color: "hvl" | "call-wall" | "put-wall";
|
|
label: string;
|
|
value: string;
|
|
from: number;
|
|
tooltipMetric: "hvl" | "callWall" | "putWall";
|
|
}) {
|
|
const numericValue = Number(value.replace(/,/g, ""));
|
|
const distPct = ((numericValue - from) / from) * 100;
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"gap-2 border-border px-2.5 py-1 font-mono text-xs",
|
|
color === "hvl" && "bg-hvl/10 text-hvl",
|
|
color === "call-wall" && "bg-call-wall/10 text-call-wall",
|
|
color === "put-wall" && "bg-put-wall/10 text-put-wall",
|
|
)}
|
|
data-testid={`badge-${tooltipMetric}`}
|
|
>
|
|
<span className="font-sans font-medium uppercase tracking-wide opacity-80">{label}</span>
|
|
<span>{value}</span>
|
|
<span className="text-foreground/70">{fmtPct(distPct)}</span>
|
|
<MetricTooltip metric={tooltipMetric} className="h-3 w-3" />
|
|
</Badge>
|
|
);
|
|
}
|