import { useCallback, useMemo, useState } from "react";
import { Box } from "../../../ui";
import { isPlainKey } from "../../../utils/keyboard";
import {
DataTableStackView,
PaneStatusBody,
CompositeChart,
StatGrid,
statGridRows,
type StatItem,
usePaneNoticeFooter,
usePaneStatusFooter,
type DataTableColumn,
} from "../../../components";
import {
useAsyncResource,
useAutoRefresh,
usePaneCollection,
usePaneSettingValue,
usePaneTitle,
usePluginPaneState,
useShortcut,
useTickers,
} from "../../../public/react";
import { useThemeColors } from "../../../theme/theme-context";
import { ApiRequestError } from "../../../api-client/errors";
import type { PaneProps } from "../../../types/plugin";
import { ScatterTrailSurface } from "../../../components/chart/static/trail-chart-surface";
import {
staticSeries,
scalarPoint,
} from "../../../components/chart/static/series";
import { useResearchCloudSession } from "../shared/research-cloud-session";
import { cachedRotation, loadRotation } from "./client";
import {
ROTATION_LIMIT,
rotationId,
rotationTrailWeeks,
rotationInstruments,
sectorRotationInstruments,
type RotationInstrument,
type RotationRow,
} from "./model";
// One hue per sector ETF, in the sector board's order (XLK, XLV, XLF, XLY,
// XLC, XLI, XLP, XLE, XLU, XLRE, XLB): no two trails share a colour family.
const PALETTE = [
"#4da3ff",
"#ff9933",
"#a78bfa",
"#4ade80",
"#ff5c5c",
"#facc15",
"#22d3ee",
"#f472b6",
"#a3e635",
"#b5835a",
"#94a3b8",
];
// The observation week is the same for every row, so it lives in the footer.
// Percentiles rank against the trailing year of weekly readings.
const COLUMNS: DataTableColumn[] = [
{ id: "symbol", label: "ETF", width: 7, align: "left" },
{ id: "label", label: "NAME", width: 16, flexGrow: 1, align: "left" },
{ id: "quadrant", label: "QUADRANT", width: 10, align: "left" },
{ id: "strength", label: "STRENGTH", width: 10, align: "right" },
{ id: "strengthRank", label: "STR PCTL", width: 8, align: "right" },
{ id: "momentum", label: "MOMENTUM", width: 10, align: "right" },
{ id: "momentumRank", label: "MOM PCTL", width: 8, align: "right" },
];
const clearDenied = (error: unknown) =>
error instanceof ApiRequestError && [401, 403].includes(error.status ?? 0);
const number = (value: number | null | undefined) =>
value == null ? "--" : value.toFixed(2);
const rank = (value: number | null) =>
value == null ? "--" : value.toFixed(0);
const PANELS = [{ id: "main" }];
function RotationDetail({
row,
width,
height,
focused = false,
}: {
row: RotationRow;
width: number;
height: number;
focused?: boolean;
}) {
const colors = useThemeColors();
const series = useMemo(
() =>
["strength", "momentum"].map((metric, index) =>
staticSeries(
row.history
.slice(-53)
.map((point) =>
scalarPoint(
new Date(point.date),
point[metric as "strength" | "momentum"],
),
),
{
id: metric,
label: metric === "strength" ? "Strength" : "Momentum",
color: index ? colors.warning : colors.positive,
calendarSpaced: true,
},
),
),
[row, colors],
);
// A thin percentile window is flagged in the footer notices, and the footer
// carries the observation week every row shares, so neither repeats here.
const rankDetail = (value: RotationRow["strengthRank"]) => `${rank(value.percentile)} pctl`;
const stats: StatItem[] = [
{ id: "strength", label: "Strength", value: number(row.strength), detail: rankDetail(row.strengthRank) },
{ id: "momentum", label: "Momentum", value: number(row.momentum), detail: rankDetail(row.momentumRank) },
{ id: "quadrant", label: "Quadrant", value: row.quadrant ?? "--" },
];
const statRows = statGridRows(stats, width);
return (
value.toFixed(1)}
/>
);
}
export function RelativeRotationPane(props: PaneProps) {
const [scope] = usePaneSettingValue("scope", "sectors");
const [symbols] = usePaneSettingValue("symbols", "");
const [benchmarkText] = usePaneSettingValue("benchmark", "SPY:NYSEARCA");
const [trailText] = usePaneSettingValue("trail", "6");
const { collectionId } = usePaneCollection();
const tickers = useTickers();
const context = useMemo(() => {
try {
const references = rotationInstruments(benchmarkText || "SPY:NYSEARCA");
if (references.length !== 1)
throw new Error("Choose one benchmark in pane settings.");
const instruments =
scope === "custom"
? rotationInstruments(symbols)
: scope === "collection"
? Object.values(tickers)
.filter(
(ticker) =>
collectionId &&
[
...ticker.metadata.watchlists,
...ticker.metadata.portfolios,
].includes(collectionId),
)
.map((ticker) => ({
symbol: ticker.metadata.symbol,
exchange: ticker.metadata.exchange,
label: ticker.metadata.symbol,
}))
: sectorRotationInstruments();
if (!instruments.length)
throw new Error(
"Choose symbols or link a populated watchlist in pane settings.",
);
if (instruments.length > ROTATION_LIMIT)
throw new Error(
`Choose a watchlist with at most ${ROTATION_LIMIT} instruments.`,
);
return { benchmark: references[0]!, instruments, error: null };
} catch (error) {
return {
benchmark: null,
instruments: [],
error:
error instanceof Error ? error.message : "Invalid rotation scope.",
};
}
}, [scope, symbols, benchmarkText, collectionId, tickers]);
const trail = rotationTrailWeeks(trailText);
return context.benchmark ? (
) : (
);
}
function RotationView({
width,
height,
focused,
benchmark,
instruments,
trail,
}: PaneProps & {
benchmark: RotationInstrument;
instruments: RotationInstrument[];
trail: number;
}) {
const session = useResearchCloudSession();
const identity = `${rotationId(benchmark)}:${instruments.map(rotationId).join(",")}:${trail}`;
const loader = useCallback(
(force: boolean) => loadRotation(benchmark, instruments, trail, force),
[identity, session.requestKey],
);
const resource = useAsyncResource(loader, {
initialData: () => cachedRotation(benchmark, instruments, trail),
clearOnError: clearDenied,
});
const [selectedId, setSelectedId] = usePluginPaneState(
"selected",
null,
);
const [openId, setOpenId] = usePluginPaneState("open", null);
const [sort, setSort] = useState({
id: "symbol",
direction: "asc" as "asc" | "desc",
});
const data = resource.data?.payload;
const selected = data?.rows.find((row) => row.id === openId);
const colorMap = useMemo(
() =>
new Map(
instruments.map((row, index) => [
rotationId(row),
PALETTE[index % PALETTE.length]!,
]),
),
[identity],
);
const trails = useMemo(
() =>
data?.rows.map((row) => ({
id: row.id,
label: row.symbol,
color: colorMap.get(row.id)!,
points: row.trail
.filter((point) => point.strength != null && point.momentum != null)
.map((point) => ({
x: point.strength!,
y: point.momentum!,
date: point.date,
})),
})) ?? [],
[data, colorMap],
);
const rows = useMemo(
() =>
[...(data?.rows ?? [])].sort((a, b) => {
const value = (row: RotationRow) =>
sort.id === "strengthRank"
? row.strengthRank.percentile
: sort.id === "momentumRank"
? row.momentumRank.percentile
: row[sort.id as "symbol"];
const left = value(a),
right = value(b);
return left == null
? right == null
? 0
: 1
: right == null
? -1
: (typeof left === "number" && typeof right === "number"
? left - right
: String(left).localeCompare(String(right))) *
(sort.direction === "asc" ? 1 : -1);
}),
[data, sort],
);
const tableHeight = Math.min(
rows.length + 2,
Math.max(4, Math.floor(height * 0.42)),
);
const chartHeight = Math.max(6, height - tableHeight);
usePaneTitle(`RRG vs ${benchmark.symbol}`);
useAutoRefresh(resource.updatedAt, resource.load);
useShortcut((event) => {
if (focused && isPlainKey(event, "r") && !event.targetEditable) {
event.preventDefault();
void resource.reload();
}
});
usePaneNoticeFooter({
registrationId: "relative-rotation:notices",
focused,
notices: [
...(data?.gaps ?? []),
...(data?.rows.flatMap((row) =>
row.gaps.map((gap) => `${row.symbol}: ${gap}`),
) ?? []),
...(resource.data?.refreshError ? [resource.data.refreshError] : []),
],
});
usePaneStatusFooter({
registrationId: "relative-rotation",
loading: resource.loading,
error: resource.error,
info: data
? [
{
id: "asof",
parts: [
{
text: `${data.currency ?? "--"} · week ${data.asOf ?? "unavailable"} · ${trail}W trails`,
tone: "muted",
},
],
},
...(resource.data?.stale
? [
{
id: "stale",
parts: [{ text: "stale", tone: "warning" as const }],
},
]
: []),
]
: [],
});
return (
{data ? (
<>
{!selected ? (
) : null}
row.id}
selection={{
kind: "id",
selectedId,
getId: (row) => row.id,
onChange: setSelectedId,
}}
onActivate={(row) => setOpenId(row.id)}
detailOpen={!!selected}
onBack={() => setOpenId(null)}
detailTitle={selected?.label}
detailContent={
selected ? (
) : null
}
sortColumnId={sort.id}
sortDirection={sort.direction}
onHeaderClick={(id) =>
setSort({
id,
direction:
sort.id === id && sort.direction === "asc" ? "desc" : "asc",
})
}
renderCell={(row, column) => ({
text:
column.id === "strengthRank"
? rank(row.strengthRank.percentile)
: column.id === "momentumRank"
? rank(row.momentumRank.percentile)
: column.id === "strength" || column.id === "momentum"
? number(row[column.id])
: String(row[column.id as "symbol"] ?? "--"),
color: column.id === "symbol" ? colorMap.get(row.id) : undefined,
})}
emptyStateTitle="No aligned weekly observations."
/>
>
) : null}
);
}