diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0f0ccc7..d42c728 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -315,6 +315,59 @@ h1 { color: #5b4c34; } +.pill { + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid rgba(111, 91, 57, 0.35); + color: #6f5b39; + background: rgba(111, 91, 57, 0.12); +} + +.severity-high { + border-color: rgba(136, 58, 17, 0.6); + color: #8c3a11; + background: rgba(196, 111, 42, 0.2); +} + +.severity-medium { + border-color: rgba(31, 74, 123, 0.35); + color: #1f4a7b; + background: rgba(31, 74, 123, 0.12); +} + +.severity-low { + border-color: rgba(47, 109, 79, 0.35); + color: #2f6d4f; + background: rgba(47, 109, 79, 0.12); +} + +.direction-bullish { + border-color: rgba(47, 109, 79, 0.35); + color: #2f6d4f; + background: rgba(47, 109, 79, 0.12); +} + +.direction-bearish { + border-color: rgba(136, 58, 17, 0.6); + color: #8c3a11; + background: rgba(196, 111, 42, 0.2); +} + +.direction-neutral { + border-color: rgba(111, 91, 57, 0.35); + color: #6f5b39; + background: rgba(111, 91, 57, 0.12); +} + +.note { + margin-top: 8px; + font-size: 0.78rem; + color: #5b4c34; +} + .flow-meta span { display: inline-flex; align-items: center; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 0ed0b09..26cf52d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { EquityPrint, FlowPacket, OptionPrint } from "@islandflow/types"; +import type { AlertEvent, ClassifierHitEvent, EquityPrint, FlowPacket, OptionPrint } from "@islandflow/types"; const MAX_ITEMS = 500; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); @@ -10,7 +10,7 @@ type WsStatus = "connecting" | "connected" | "disconnected"; type TapeMode = "live" | "replay"; -type MessageType = "option-print" | "equity-print" | "flow-packet"; +type MessageType = "option-print" | "equity-print" | "flow-packet" | "classifier-hit" | "alert"; type StreamMessage = { type: MessageType; @@ -161,6 +161,27 @@ const formatTime = (ts: number): string => { return new Date(ts).toLocaleTimeString(); }; +const formatConfidence = (value: number): string => `${Math.round(value * 100)}%`; + +const humanizeClassifierId = (value: string): string => { + if (!value) { + return "Classifier"; + } + + return value + .split("_") + .map((part) => (part ? part[0].toUpperCase() + part.slice(1) : part)) + .join(" "); +}; + +const normalizeDirection = (value: string): "bullish" | "bearish" | "neutral" => { + const normalized = value.toLowerCase(); + if (normalized === "bullish" || normalized === "bearish" || normalized === "neutral") { + return normalized; + } + return "neutral"; +}; + const parseNumber = (value: unknown, fallback: number): number => { if (typeof value === "number" && Number.isFinite(value)) { return value; @@ -582,12 +603,14 @@ const useTape = ( }; }; -const useFlowStream = ( +const useLiveStream = ( enabled: boolean, + wsPath: string, + expectedType: MessageType, onNewItems?: (count: number) => void -): TapeState => { +): TapeState => { const [status, setStatus] = useState(enabled ? "connecting" : "disconnected"); - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [lastUpdate, setLastUpdate] = useState(null); const [replayTime] = useState(null); const [replayComplete] = useState(false); @@ -614,6 +637,8 @@ const useFlowStream = ( useEffect(() => { if (!enabled) { setStatus("disconnected"); + setItems([]); + setLastUpdate(null); return; } @@ -626,7 +651,7 @@ const useFlowStream = ( setStatus("connecting"); - const socket = new WebSocket(buildWsUrl("/ws/flow")); + const socket = new WebSocket(buildWsUrl(wsPath)); socketRef.current = socket; socket.onopen = () => { @@ -642,8 +667,8 @@ const useFlowStream = ( } try { - const message = JSON.parse(event.data) as StreamMessage; - if (!message || message.type !== "flow-packet") { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== expectedType) { return; } @@ -660,7 +685,7 @@ const useFlowStream = ( setItems((prev) => mergeNewest([message.payload], prev)); setLastUpdate(Date.now()); } catch (error) { - console.warn("Failed to parse flow packet", error); + console.warn("Failed to parse live stream payload", error); } }; @@ -696,7 +721,7 @@ const useFlowStream = ( socketRef.current.close(); } }; - }, [enabled]); + }, [enabled, expectedType, wsPath, onNewItems]); return { status, @@ -710,6 +735,13 @@ const useFlowStream = ( }; }; +const useFlowStream = ( + enabled: boolean, + onNewItems?: (count: number) => void +): TapeState => { + return useLiveStream(enabled, "/ws/flow", "flow-packet", onNewItems); +}; + type TapeStatusProps = { status: WsStatus; lastUpdate: number | null; @@ -790,6 +822,8 @@ export default function HomePage() { const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); + const alertsScroll = useListScroll(); + const classifierScroll = useListScroll(); const options = useTape({ mode, @@ -814,12 +848,36 @@ export default function HomePage() { }); const flow = useFlowStream(mode === "live", flowScroll.onNewItems); + const alerts = useLiveStream( + mode === "live", + "/ws/alerts", + "alert", + alertsScroll.onNewItems + ); + const classifierHits = useLiveStream( + mode === "live", + "/ws/classifier-hits", + "classifier-hit", + classifierScroll.onNewItems + ); const lastSeen = useMemo(() => { - return [options.lastUpdate, equities.lastUpdate, flow.lastUpdate] + return [ + options.lastUpdate, + equities.lastUpdate, + flow.lastUpdate, + alerts.lastUpdate, + classifierHits.lastUpdate + ] .filter((value): value is number => value !== null) .sort((a, b) => b - a)[0] ?? null; - }, [options.lastUpdate, equities.lastUpdate, flow.lastUpdate]); + }, [ + options.lastUpdate, + equities.lastUpdate, + flow.lastUpdate, + alerts.lastUpdate, + classifierHits.lastUpdate + ]); const toggleMode = () => { setMode((prev) => (prev === "live" ? "replay" : "live")); @@ -1018,6 +1076,120 @@ export default function HomePage() { )} + +
+
+
+

Alerts

+

Rule-based scoring from flow packets.

+
+
+
+ + +
+ +
+ {mode !== "live" ? ( +
Alerts are live-only in this build.
+ ) : alerts.items.length === 0 ? ( +
No alerts yet. Start compute.
+ ) : ( + alerts.items.map((alert) => { + const primary = alert.hits[0]; + const direction = primary ? normalizeDirection(primary.direction) : "neutral"; + + return ( +
+
+
+ {primary ? humanizeClassifierId(primary.classifier_id) : "Alert"} +
+
+ {alert.severity} + Score {Math.round(alert.score)} + {alert.hits.length} hits + {primary ? ( + {direction} + ) : null} +
+ {primary?.explanations?.[0] ? ( +
{primary.explanations[0]}
+ ) : null} +
+
{formatTime(alert.source_ts)}
+
+ ); + }) + )} +
+
+ +
+
+
+

Classifier Hits

+

Raw rule hits before alert scoring.

+
+
+
+ + +
+ +
+ {mode !== "live" ? ( +
Classifier hits are live-only in this build.
+ ) : classifierHits.items.length === 0 ? ( +
No classifier hits yet. Start compute.
+ ) : ( + classifierHits.items.map((hit) => { + const direction = normalizeDirection(hit.direction); + return ( +
+
+
{humanizeClassifierId(hit.classifier_id)}
+
+ {direction} + Confidence {formatConfidence(hit.confidence)} +
+ {hit.explanations?.[0] ? ( +
{hit.explanations[0]}
+ ) : null} +
+
{formatTime(hit.source_ts)}
+
+ ); + }) + )} +
+
);