diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index d2b0953..f519407 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -78,6 +78,70 @@ h1 {
min-width: 220px;
}
+.filter-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 16px 20px;
+ border-radius: 18px;
+ border: 1px solid var(--panel-border);
+ background: rgba(255, 253, 247, 0.9);
+}
+
+.filter-label {
+ margin: 0 0 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.3em;
+ font-size: 0.7rem;
+ color: #6f5b39;
+}
+
+.filter-help {
+ margin: 0;
+ color: #4e3e25;
+ font-size: 0.9rem;
+}
+
+.filter-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.filter-input {
+ border: 1px solid rgba(111, 91, 57, 0.35);
+ border-radius: 999px;
+ padding: 8px 14px;
+ min-width: 220px;
+ background: #fffdf7;
+ font-family: inherit;
+ font-size: 0.9rem;
+ color: #1d1d1b;
+}
+
+.filter-input:focus-visible {
+ outline: 2px solid rgba(47, 109, 79, 0.3);
+ outline-offset: 2px;
+}
+
+.filter-clear {
+ border: 1px solid rgba(111, 91, 57, 0.35);
+ border-radius: 999px;
+ padding: 6px 12px;
+ background: rgba(111, 91, 57, 0.08);
+ color: #6f5b39;
+ font-size: 0.7rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.filter-clear:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
.summary-title {
font-size: 0.75rem;
text-transform: uppercase;
@@ -316,6 +380,24 @@ h1 {
border: 1px solid rgba(217, 205, 184, 0.6);
}
+.row-button {
+ width: 100%;
+ text-align: left;
+ cursor: pointer;
+ font: inherit;
+ color: inherit;
+}
+
+.row-button:hover {
+ border-color: rgba(47, 109, 79, 0.4);
+ box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12);
+}
+
+.row-button:focus-visible {
+ outline: 2px solid rgba(47, 109, 79, 0.4);
+ outline-offset: 2px;
+}
+
.contract {
font-weight: 600;
margin-bottom: 6px;
@@ -382,6 +464,176 @@ h1 {
color: #5b4c34;
}
+.drawer {
+ position: fixed;
+ top: 88px;
+ right: 6vw;
+ width: min(360px, 92vw);
+ max-height: calc(100vh - 140px);
+ overflow: auto;
+ padding: 20px;
+ border-radius: 20px;
+ border: 1px solid var(--panel-border);
+ background: #fffdf7;
+ box-shadow: 0 32px 60px rgba(66, 45, 18, 0.22);
+ z-index: 30;
+}
+
+.drawer-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 12px;
+}
+
+.drawer-eyebrow {
+ margin: 0 0 6px;
+ text-transform: uppercase;
+ letter-spacing: 0.3em;
+ font-size: 0.65rem;
+ color: #6f5b39;
+}
+
+.drawer h3 {
+ margin: 0 0 4px;
+ font-size: 1.1rem;
+}
+
+.drawer-subtitle {
+ margin: 0;
+ color: #6f5b39;
+ font-size: 0.8rem;
+}
+
+.drawer-close {
+ border: 1px solid rgba(111, 91, 57, 0.35);
+ border-radius: 999px;
+ padding: 6px 12px;
+ background: rgba(111, 91, 57, 0.08);
+ color: #6f5b39;
+ font-size: 0.7rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.drawer-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.drawer-chip {
+ padding: 2px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(111, 91, 57, 0.35);
+ background: rgba(111, 91, 57, 0.08);
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.drawer-section {
+ margin-bottom: 18px;
+}
+
+.drawer-section h4 {
+ margin: 0 0 10px;
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.22em;
+ color: #6f5b39;
+}
+
+.drawer-list {
+ display: grid;
+ gap: 12px;
+}
+
+.drawer-row {
+ padding: 12px 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(217, 205, 184, 0.6);
+ background: rgba(255, 255, 255, 0.75);
+}
+
+.drawer-row-title {
+ font-weight: 600;
+ margin-bottom: 6px;
+}
+
+.drawer-row-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ font-size: 0.75rem;
+ color: #5b4c34;
+}
+
+.drawer-note {
+ margin: 8px 0 0;
+ font-size: 0.72rem;
+ color: #5b4c34;
+}
+
+.drawer-empty {
+ margin: 0;
+ font-size: 0.78rem;
+ color: #6f5b39;
+}
+
+.severity-strip {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(217, 205, 184, 0.6);
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.severity-strip-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ color: #6f5b39;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+}
+
+.severity-strip-bar {
+ display: flex;
+ height: 26px;
+ border-radius: 999px;
+ overflow: hidden;
+ border: 1px solid rgba(217, 205, 184, 0.6);
+ background: rgba(111, 91, 57, 0.08);
+}
+
+.severity-segment {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ color: #fffdf7;
+ letter-spacing: 0.08em;
+}
+
+.severity-strip .severity-high {
+ background: rgba(196, 111, 42, 0.85);
+ color: #3b1a09;
+}
+
+.severity-strip .severity-medium {
+ background: rgba(31, 74, 123, 0.8);
+}
+
+.severity-strip .severity-low {
+ background: rgba(47, 109, 79, 0.8);
+}
+
.flow-meta span {
display: inline-flex;
align-items: center;
@@ -436,6 +688,21 @@ h1 {
padding: 36px 6vw 56px;
}
+ .filter-bar {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .filter-controls {
+ width: 100%;
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-input {
+ width: 100%;
+ }
+
.row {
flex-direction: column;
align-items: flex-start;
@@ -444,6 +711,12 @@ h1 {
.time {
text-align: left;
}
+
+ .drawer {
+ position: static;
+ width: 100%;
+ max-height: none;
+ }
}
@media (max-width: 1100px) {
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 2b39715..41acf38 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -163,6 +163,11 @@ const formatTime = (ts: number): string => {
const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`;
+const formatDateTime = (ts: number): string => {
+ const date = new Date(ts);
+ return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
+};
+
const humanizeClassifierId = (value: string): string => {
if (!value) {
return "Classifier";
@@ -182,6 +187,14 @@ const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" =>
return "neutral";
};
+const extractUnderlying = (contractId: string): string => {
+ const match = contractId.match(/^(.+)-\d{4}-\d{2}-\d{2}-/);
+ if (match?.[1]) {
+ return match[1].toUpperCase();
+ }
+ return contractId.split("-")[0]?.toUpperCase() ?? contractId.toUpperCase();
+};
+
const parseNumber = (value: unknown, fallback: number): number => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
@@ -809,6 +822,167 @@ const TapeControls = ({ isAtTop, missed, onJump }: TapeControlsProps) => {
);
};
+type AlertSeverityStripProps = {
+ alerts: AlertEvent[];
+};
+
+const AlertSeverityStrip = ({ alerts }: AlertSeverityStripProps) => {
+ const windowMs = 30 * 60 * 1000;
+ const now = Date.now();
+ const counts = alerts.reduce(
+ (acc, alert) => {
+ if (now - alert.source_ts > windowMs) {
+ return acc;
+ }
+ if (alert.severity === "high") {
+ acc.high += 1;
+ } else if (alert.severity === "medium") {
+ acc.medium += 1;
+ } else {
+ acc.low += 1;
+ }
+ return acc;
+ },
+ { high: 0, medium: 0, low: 0 }
+ );
+
+ const total = counts.high + counts.medium + counts.low;
+ const highPct = total > 0 ? (counts.high / total) * 100 : 0;
+ const mediumPct = total > 0 ? (counts.medium / total) * 100 : 0;
+ const lowPct = total > 0 ? (counts.low / total) * 100 : 0;
+
+ return (
+
+
+ Last 30m
+ {total} alerts
+
+
+
+ {counts.high > 0 ? counts.high : ""}
+
+
+ {counts.medium > 0 ? counts.medium : ""}
+
+
+ {counts.low > 0 ? counts.low : ""}
+
+
+
+ );
+};
+
+type EvidenceItem =
+ | { kind: "flow"; id: string; packet: FlowPacket }
+ | { kind: "print"; id: string; print: OptionPrint }
+ | { kind: "unknown"; id: string };
+
+type AlertDrawerProps = {
+ alert: AlertEvent;
+ flowPacket: FlowPacket | null;
+ evidence: EvidenceItem[];
+ onClose: () => void;
+};
+
+const AlertDrawer = ({ alert, flowPacket, evidence, onClose }: AlertDrawerProps) => {
+ const primary = alert.hits[0];
+ const direction = primary ? normalizeDirection(primary.direction) : "neutral";
+ const evidencePrints = evidence.filter((item) => item.kind === "print");
+ const unknownCount = evidence.filter((item) => item.kind === "unknown").length;
+
+ return (
+
+ );
+};
+
const formatFlowMetric = (value: number, suffix?: string): string => {
if (suffix) {
return `${value}${suffix}`;
@@ -819,6 +993,8 @@ const formatFlowMetric = (value: number, suffix?: string): string => {
export default function HomePage() {
const [mode, setMode] = useState("live");
+ const [selectedAlert, setSelectedAlert] = useState(null);
+ const [filterInput, setFilterInput] = useState("");
const optionsScroll = useListScroll();
const equitiesScroll = useListScroll();
const flowScroll = useListScroll();
@@ -861,6 +1037,165 @@ export default function HomePage() {
classifierScroll.onNewItems
);
+ const activeTickers = useMemo(() => {
+ const parts = filterInput
+ .split(/[,\s]+/)
+ .map((value) => value.trim().toUpperCase())
+ .filter(Boolean);
+ return Array.from(new Set(parts));
+ }, [filterInput]);
+
+ const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
+
+ const optionPrintMap = useMemo(() => {
+ const map = new Map();
+ for (const print of options.items) {
+ if (print.trace_id) {
+ map.set(print.trace_id, print);
+ }
+ }
+ return map;
+ }, [options.items]);
+
+ const flowPacketMap = useMemo(() => {
+ const map = new Map();
+ for (const packet of flow.items) {
+ map.set(packet.id, packet);
+ }
+ return map;
+ }, [flow.items]);
+
+ const selectedEvidence = useMemo((): EvidenceItem[] => {
+ if (!selectedAlert) {
+ return [];
+ }
+
+ return selectedAlert.evidence_refs.map((id) => {
+ const packet = flowPacketMap.get(id);
+ if (packet) {
+ return { kind: "flow", id, packet };
+ }
+ const print = optionPrintMap.get(id);
+ if (print) {
+ return { kind: "print", id, print };
+ }
+ return { kind: "unknown", id };
+ });
+ }, [selectedAlert, flowPacketMap, optionPrintMap]);
+
+ const selectedFlowPacket = useMemo(() => {
+ if (!selectedAlert) {
+ return null;
+ }
+ const packetId = selectedAlert.evidence_refs[0];
+ return packetId ? flowPacketMap.get(packetId) ?? null : null;
+ }, [selectedAlert, flowPacketMap]);
+
+ useEffect(() => {
+ if (mode !== "live") {
+ setSelectedAlert(null);
+ }
+ }, [mode]);
+
+ const extractPacketContract = useCallback((packet: FlowPacket): string => {
+ const contract = packet.features.option_contract_id;
+ if (typeof contract === "string") {
+ return contract;
+ }
+ const match = packet.id.match(/^flowpacket:([^:]+):/);
+ return match?.[1] ?? packet.id;
+ }, []);
+
+ const extractUnderlyingFromTrace = useCallback((traceId: string): string | null => {
+ const match = traceId.match(/flowpacket:([^:]+):/);
+ if (!match?.[1]) {
+ return null;
+ }
+ return extractUnderlying(match[1]);
+ }, []);
+
+ const inferAlertUnderlying = useCallback(
+ (alert: AlertEvent): string | null => {
+ const fromTrace = extractUnderlyingFromTrace(alert.trace_id);
+ if (fromTrace) {
+ return fromTrace;
+ }
+
+ const packetId = alert.evidence_refs[0];
+ if (packetId) {
+ const packet = flowPacketMap.get(packetId);
+ if (packet) {
+ return extractUnderlying(extractPacketContract(packet));
+ }
+ }
+
+ for (const ref of alert.evidence_refs) {
+ const print = optionPrintMap.get(ref);
+ if (print) {
+ return extractUnderlying(print.option_contract_id);
+ }
+ }
+
+ return null;
+ },
+ [extractPacketContract, extractUnderlyingFromTrace, flowPacketMap, optionPrintMap]
+ );
+
+ const matchesTicker = useCallback(
+ (value: string | null) => {
+ if (tickerSet.size === 0) {
+ return true;
+ }
+ if (!value) {
+ return false;
+ }
+ return tickerSet.has(value.toUpperCase());
+ },
+ [tickerSet]
+ );
+
+ const filteredOptions = useMemo(() => {
+ if (tickerSet.size === 0) {
+ return options.items;
+ }
+ return options.items.filter((print) =>
+ matchesTicker(extractUnderlying(print.option_contract_id))
+ );
+ }, [options.items, matchesTicker, tickerSet]);
+
+ const filteredEquities = useMemo(() => {
+ if (tickerSet.size === 0) {
+ return equities.items;
+ }
+ return equities.items.filter((print) => matchesTicker(print.underlying_id));
+ }, [equities.items, matchesTicker, tickerSet]);
+
+ const filteredFlow = useMemo(() => {
+ if (tickerSet.size === 0) {
+ return flow.items;
+ }
+ return flow.items.filter((packet) =>
+ matchesTicker(extractUnderlying(extractPacketContract(packet)))
+ );
+ }, [flow.items, extractPacketContract, matchesTicker, tickerSet]);
+
+ const filteredAlerts = useMemo(() => {
+ if (tickerSet.size === 0) {
+ return alerts.items;
+ }
+ return alerts.items.filter((alert) => matchesTicker(inferAlertUnderlying(alert)));
+ }, [alerts.items, inferAlertUnderlying, matchesTicker, tickerSet]);
+
+ const filteredClassifierHits = useMemo(() => {
+ if (tickerSet.size === 0) {
+ return classifierHits.items;
+ }
+ return classifierHits.items.filter((hit) => {
+ const underlying = extractUnderlyingFromTrace(hit.trace_id);
+ return matchesTicker(underlying);
+ });
+ }, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]);
+
const lastSeen = useMemo(() => {
return [
options.lastUpdate,
@@ -904,6 +1239,31 @@ export default function HomePage() {
+
+
+
Ticker filter
+
+ {activeTickers.length > 0 ? `Filtering ${activeTickers.join(", ")}` : "All tickers"}
+
+
+
+ setFilterInput(event.target.value)}
+ placeholder="SPY, NVDA, AAPL"
+ />
+
+
+
+
@@ -931,14 +1291,16 @@ export default function HomePage() {
- {options.items.length === 0 ? (
+ {filteredOptions.length === 0 ? (
- {mode === "live"
- ? "No option prints yet. Start ingest-options."
- : "Replay queue empty. Ensure ClickHouse has data."}
+ {tickerSet.size > 0
+ ? "No option prints match the current filter."
+ : mode === "live"
+ ? "No option prints yet. Start ingest-options."
+ : "Replay queue empty. Ensure ClickHouse has data."}
) : (
- options.items.map((print) => (
+ filteredOptions.map((print) => (
{print.option_contract_id}
@@ -984,14 +1346,16 @@ export default function HomePage() {
- {equities.items.length === 0 ? (
+ {filteredEquities.length === 0 ? (
- {mode === "live"
- ? "No equity prints yet. Start ingest-equities."
- : "Replay queue empty. Ensure ClickHouse has data."}
+ {tickerSet.size > 0
+ ? "No equity prints match the current filter."
+ : mode === "live"
+ ? "No equity prints yet. Start ingest-equities."
+ : "Replay queue empty. Ensure ClickHouse has data."}
) : (
- equities.items.map((print) => (
+ filteredEquities.map((print) => (
{print.underlying_id}
@@ -1041,10 +1405,14 @@ export default function HomePage() {
{mode !== "live" ? (
Flow packets are live-only in this build.
- ) : flow.items.length === 0 ? (
-
No flow packets yet. Start compute.
+ ) : filteredFlow.length === 0 ? (
+
+ {tickerSet.size > 0
+ ? "No flow packets match the current filter."
+ : "No flow packets yet. Start compute."}
+
) : (
- flow.items.map((packet) => {
+ filteredFlow.map((packet) => {
const features = packet.features ?? {};
const contract = String(features.option_contract_id ?? packet.id ?? "unknown");
const count = parseNumber(features.count, packet.members.length);
@@ -1102,18 +1470,29 @@ export default function HomePage() {
/>
+
+
{mode !== "live" ? (
Alerts are live-only in this build.
- ) : alerts.items.length === 0 ? (
-
No alerts yet. Start compute.
+ ) : filteredAlerts.length === 0 ? (
+
+ {tickerSet.size > 0
+ ? "No alerts match the current filter."
+ : "No alerts yet. Start compute."}
+
) : (
- alerts.items.map((alert) => {
+ filteredAlerts.map((alert) => {
const primary = alert.hits[0];
const direction = primary ? normalizeDirection(primary.direction) : "neutral";
return (
-
+
);
})
)}
@@ -1166,10 +1545,14 @@ export default function HomePage() {
{mode !== "live" ? (
Classifier hits are live-only in this build.
- ) : classifierHits.items.length === 0 ? (
-
No classifier hits yet. Start compute.
+ ) : filteredClassifierHits.length === 0 ? (
+
+ {tickerSet.size > 0
+ ? "No classifier hits match the current filter."
+ : "No classifier hits yet. Start compute."}
+
) : (
- classifierHits.items.map((hit) => {
+ filteredClassifierHits.map((hit) => {
const direction = normalizeDirection(hit.direction);
return (
@@ -1191,6 +1574,15 @@ export default function HomePage() {
+
+ {selectedAlert ? (
+
setSelectedAlert(null)}
+ />
+ ) : null}
);
}