diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 471b404..5d46329 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -180,6 +180,10 @@ h1 { grid-column: span 4; } +.card-chart { + grid-column: span 6; +} + .card-equities { grid-column: span 2; } @@ -274,6 +278,108 @@ h1 { flex: 0 0 auto; } +.chart-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + flex-wrap: wrap; +} + +.chart-intervals { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.interval-button { + 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.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.interval-button.active { + border-color: rgba(47, 109, 79, 0.6); + background: rgba(47, 109, 79, 0.1); + color: #2f6d4f; + box-shadow: 0 0 0 2px rgba(47, 109, 79, 0.12); +} + +.interval-button:focus-visible { + outline: 2px solid rgba(47, 109, 79, 0.4); + outline-offset: 2px; +} + +.chart-hint { + font-size: 0.8rem; + color: #6f5b39; +} + +.chart-panel { + display: grid; + gap: 16px; +} + +.chart-meta { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + font-size: 0.8rem; + color: #5b4c34; +} + +.chart-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.chart-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(111, 91, 57, 0.4); +} + +.chart-status-connected .chart-dot { + background: rgba(47, 109, 79, 0.8); +} + +.chart-status-connecting .chart-dot { + background: rgba(31, 74, 123, 0.8); + animation: pulse 1.4s ease-in-out infinite; +} + +.chart-status-disconnected .chart-dot { + background: rgba(196, 111, 42, 0.8); +} + +.chart-meta-time { + color: #6f5b39; +} + +.chart-surface { + width: 100%; + height: 360px; + border-radius: 18px; + border: 1px solid rgba(217, 205, 184, 0.6); + background: #fffdf7; + overflow: hidden; +} + +.chart-empty { + margin-top: -4px; +} + .tape-controls { display: flex; flex-direction: column; @@ -854,6 +960,7 @@ h1 { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .card-chart, .card-options, .card-equities, .card-flow, @@ -869,6 +976,7 @@ h1 { grid-template-columns: minmax(0, 1fr); } + .card-chart, .card-options, .card-equities, .card-flow, @@ -930,4 +1038,8 @@ h1 { .card-dark { height: 780px; } + + .chart-surface { + height: 280px; + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 9da86ff..3828c78 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import type { AlertEvent, ClassifierHitEvent, + EquityCandle, EquityPrint, EquityPrintJoin, FlowPacket, @@ -11,12 +12,56 @@ import type { OptionNBBO, OptionPrint } from "@islandflow/types"; +import { createChart, type IChartApi, type UTCTimestamp } from "lightweight-charts"; const MAX_ITEMS = 500; const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS); const NBBO_MAX_AGE_MS_SAFE = Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); +const CANDLE_INTERVALS = [ + { label: "1s", ms: 1000 }, + { label: "5s", ms: 5000 }, + { label: "1m", ms: 60000 } +]; + +type CandlestickSeries = ReturnType; + +type ChartCandle = { + time: UTCTimestamp; + open: number; + high: number; + low: number; + close: number; +}; + +const formatIntervalLabel = (intervalMs: number): string => { + const match = CANDLE_INTERVALS.find((interval) => interval.ms === intervalMs); + if (match) { + return match.label; + } + if (intervalMs >= 60000) { + return `${Math.round(intervalMs / 60000)}m`; + } + if (intervalMs >= 1000) { + return `${Math.round(intervalMs / 1000)}s`; + } + return `${intervalMs}ms`; +}; + +const toChartTime = (ts: number): UTCTimestamp => { + return Math.floor(ts / 1000) as UTCTimestamp; +}; + +const toChartCandle = (candle: EquityCandle): ChartCandle => { + return { + time: toChartTime(candle.ts), + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close + }; +}; type WsStatus = "connecting" | "connected" | "disconnected"; @@ -26,6 +71,7 @@ type MessageType = | "option-print" | "option-nbbo" | "equity-print" + | "equity-candle" | "equity-join" | "flow-packet" | "inferred-dark" @@ -1168,6 +1214,289 @@ const TapeControls = ({ isAtTop, missed, onJump }: TapeControlsProps) => { ); }; +type CandleChartProps = { + ticker: string; + intervalMs: number; + mode: TapeMode; +}; + +const CandleChart = ({ ticker, intervalMs, mode }: CandleChartProps) => { + const containerRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef(null); + const socketRef = useRef(null); + const reconnectRef = useRef(null); + const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null); + const [ready, setReady] = useState(false); + const [status, setStatus] = useState(mode === "live" ? "connecting" : "connected"); + const [lastUpdate, setLastUpdate] = useState(null); + const [hasData, setHasData] = useState(false); + const [error, setError] = useState(null); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const width = container.clientWidth || 600; + const height = container.clientHeight || 360; + const chart = createChart(container, { + width, + height, + layout: { + background: { color: "#fffdf7" }, + textColor: "#4e3e25" + }, + grid: { + vertLines: { color: "rgba(82, 64, 36, 0.12)" }, + horzLines: { color: "rgba(82, 64, 36, 0.12)" } + }, + crosshair: { + vertLine: { color: "rgba(47, 109, 79, 0.35)" }, + horzLine: { color: "rgba(47, 109, 79, 0.35)" } + }, + timeScale: { + borderColor: "rgba(111, 91, 57, 0.35)", + timeVisible: true, + secondsVisible: intervalMs < 60000 + }, + rightPriceScale: { + borderColor: "rgba(111, 91, 57, 0.35)" + } + }); + + const series = chart.addCandlestickSeries({ + upColor: "#2f6d4f", + downColor: "#c46f2a", + borderVisible: false, + wickUpColor: "#2f6d4f", + wickDownColor: "#c46f2a" + }); + + chartRef.current = chart; + seriesRef.current = series; + setReady(true); + + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) { + return; + } + const { width: nextWidth, height: nextHeight } = entry.contentRect; + if (Number.isFinite(nextWidth) && Number.isFinite(nextHeight)) { + chart.applyOptions({ + width: Math.max(1, Math.floor(nextWidth)), + height: Math.max(1, Math.floor(nextHeight)) + }); + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + }; + }, []); + + useEffect(() => { + if (!ready || !seriesRef.current) { + return; + } + + let active = true; + setError(null); + setHasData(false); + setLastUpdate(null); + lastCandleRef.current = null; + seriesRef.current.setData([]); + setStatus(mode === "live" ? "connecting" : "connected"); + + const fetchCandles = async () => { + try { + const url = new URL(buildApiUrl("/candles/equities")); + url.searchParams.set("underlying_id", ticker); + url.searchParams.set("interval_ms", intervalMs.toString()); + url.searchParams.set("limit", "300"); + url.searchParams.set("cache", "1"); + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Candle fetch failed (${response.status})`); + } + const payload = (await response.json()) as { data?: EquityCandle[] }; + if (!active || !seriesRef.current) { + return; + } + const sorted = [...(payload.data ?? [])].sort((a, b) => { + if (a.ts !== b.ts) { + return a.ts - b.ts; + } + return a.seq - b.seq; + }); + const chartData = sorted.map(toChartCandle); + seriesRef.current.setData(chartData); + chartRef.current?.timeScale().fitContent(); + + if (sorted.length > 0) { + const last = sorted[sorted.length - 1]; + lastCandleRef.current = { time: toChartTime(last.ts), seq: last.seq }; + setHasData(true); + setLastUpdate(last.ingest_ts ?? last.ts); + } + } catch (error) { + if (!active) { + return; + } + setError(error instanceof Error ? error.message : String(error)); + setStatus("disconnected"); + setHasData(false); + } + }; + + void fetchCandles(); + + return () => { + active = false; + }; + }, [ready, ticker, intervalMs, mode]); + + useEffect(() => { + if (!ready || mode !== "live" || !seriesRef.current) { + if (socketRef.current) { + socketRef.current.close(); + } + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + reconnectRef.current = null; + } + return; + } + + let active = true; + + const connect = () => { + if (!active) { + return; + } + + setStatus("connecting"); + const socket = new WebSocket(buildWsUrl("/ws/equity-candles")); + socketRef.current = socket; + + socket.onopen = () => { + if (!active) { + return; + } + setStatus("connected"); + }; + + socket.onmessage = (event) => { + if (!active || !seriesRef.current) { + return; + } + + try { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== "equity-candle") { + return; + } + + const candle = message.payload; + if (candle.underlying_id !== ticker || candle.interval_ms !== intervalMs) { + return; + } + + const chartCandle = toChartCandle(candle); + const last = lastCandleRef.current; + if (last) { + if (chartCandle.time < last.time) { + return; + } + if (chartCandle.time === last.time && candle.seq <= last.seq) { + return; + } + } + + seriesRef.current.update(chartCandle); + lastCandleRef.current = { time: chartCandle.time, seq: candle.seq }; + setHasData(true); + setLastUpdate(candle.ingest_ts ?? candle.ts); + } catch (error) { + console.warn("Failed to parse candle payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + setStatus("disconnected"); + reconnectRef.current = window.setTimeout(connect, 1000); + }; + + socket.onerror = () => { + if (!active) { + return; + } + setStatus("disconnected"); + socket.close(); + }; + }; + + connect(); + + return () => { + active = false; + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + reconnectRef.current = null; + } + if (socketRef.current) { + socketRef.current.close(); + } + }; + }, [ready, mode, ticker, intervalMs]); + + useEffect(() => { + if (!chartRef.current) { + return; + } + chartRef.current.timeScale().applyOptions({ + timeVisible: true, + secondsVisible: intervalMs < 60000 + }); + }, [intervalMs]); + + const statusText = statusLabel(status, false, mode); + + return ( +
+
+
+ + {statusText} +
+ + {lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"} + +
+
+ {error ? ( +
Chart error: {error}
+ ) : !hasData ? ( +
+ {mode === "live" + ? "No candles yet. Start candles service." + : "No candles for this replay window."} +
+ ) : null} +
+ ); +}; + type AlertSeverityStripProps = { alerts: AlertEvent[]; }; @@ -1503,6 +1832,7 @@ export default function HomePage() { const [selectedAlert, setSelectedAlert] = useState(null); const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); const [filterInput, setFilterInput] = useState(""); + const [chartIntervalMs, setChartIntervalMs] = useState(CANDLE_INTERVALS[0].ms); const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); @@ -1635,6 +1965,7 @@ export default function HomePage() { }, [filterInput]); const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]); + const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]); const nbboMap = useMemo(() => { const map = new Map(); @@ -1921,6 +2252,37 @@ export default function HomePage() {
+
+
+
+

Equity Chart

+

+ Server-built {formatIntervalLabel(chartIntervalMs)} candles for {chartTicker}. +

+
+
+
+
+ {CANDLE_INTERVALS.map((interval) => ( + + ))} +
+ {activeTickers.length > 1 ? ( + Charting first of {activeTickers.length} tickers + ) : ( + Charting {chartTicker} + )} +
+ +
+
diff --git a/apps/web/package.json b/apps/web/package.json index edab5bd..b61eb2e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@islandflow/types": "workspace:*", + "lightweight-charts": "^4.2.0", "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/bun.lock b/bun.lock index 557deeb..0408e06 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "name": "@islandflow/web", "dependencies": { "@islandflow/types": "workspace:*", + "lightweight-charts": "^4.2.0", "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -205,10 +206,14 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "fancy-canvas": ["fancy-canvas@2.1.0", "", {}, "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "lightweight-charts": ["lightweight-charts@4.2.3", "", { "dependencies": { "fancy-canvas": "2.1.0" } }, "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],