Overlay off-exchange equity prints on chart
Adds a canvas overlay to the equity candle chart to render off-exchange prints with a toggle/legend, fetching viewport-bounded data and updating live via the equities websocket.
This commit is contained in:
parent
52f7ad82c6
commit
4dd7b03810
2 changed files with 402 additions and 3 deletions
|
|
@ -336,6 +336,39 @@ h1 {
|
||||||
color: #5b4c34;
|
color: #5b4c34;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-toggle {
|
||||||
|
border: 1px solid rgba(31, 74, 123, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(31, 74, 123, 0.12);
|
||||||
|
color: #1f4a7b;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-toggle.overlay-toggle-on {
|
||||||
|
border-color: rgba(31, 74, 123, 0.6);
|
||||||
|
background: rgba(31, 74, 123, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-toggle:focus-visible {
|
||||||
|
outline: 2px solid rgba(31, 74, 123, 0.4);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-legend {
|
||||||
|
color: #6f5b39;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.overlay-legend {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chart-status {
|
.chart-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ const CANDLE_INTERVALS = [
|
||||||
|
|
||||||
type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
|
type CandlestickSeries = ReturnType<IChartApi["addCandlestickSeries"]>;
|
||||||
|
|
||||||
|
type EquityOverlayPoint = {
|
||||||
|
ts: number;
|
||||||
|
price: number;
|
||||||
|
size: number;
|
||||||
|
offExchangeFlag: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type ChartCandle = {
|
type ChartCandle = {
|
||||||
time: UTCTimestamp;
|
time: UTCTimestamp;
|
||||||
open: number;
|
open: number;
|
||||||
|
|
@ -52,6 +59,37 @@ const toChartTime = (ts: number): UTCTimestamp => {
|
||||||
return Math.floor(ts / 1000) as UTCTimestamp;
|
return Math.floor(ts / 1000) as UTCTimestamp;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ChartTimeLike = number | string | { year: number; month: number; day: number };
|
||||||
|
|
||||||
|
const chartTimeToMs = (value: ChartTimeLike): number | null => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Math.floor(value * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const { year, month, day } = value;
|
||||||
|
if (
|
||||||
|
Number.isFinite(year) &&
|
||||||
|
Number.isFinite(month) &&
|
||||||
|
Number.isFinite(day) &&
|
||||||
|
year >= 1970 &&
|
||||||
|
month >= 1 &&
|
||||||
|
month <= 12 &&
|
||||||
|
day >= 1 &&
|
||||||
|
day <= 31
|
||||||
|
) {
|
||||||
|
return Date.UTC(year, month - 1, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const toChartCandle = (candle: EquityCandle): ChartCandle => {
|
const toChartCandle = (candle: EquityCandle): ChartCandle => {
|
||||||
return {
|
return {
|
||||||
time: toChartTime(candle.ts),
|
time: toChartTime(candle.ts),
|
||||||
|
|
@ -62,6 +100,28 @@ const toChartCandle = (candle: EquityCandle): ChartCandle => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sampleToLimit = <T,>(items: T[], limit: number): T[] => {
|
||||||
|
if (items.length <= limit) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeLimit = Math.max(1, Math.floor(limit));
|
||||||
|
const step = Math.ceil(items.length / safeLimit);
|
||||||
|
const sampled: T[] = [];
|
||||||
|
for (let idx = 0; idx < items.length; idx += step) {
|
||||||
|
sampled.push(items[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sampled;
|
||||||
|
};
|
||||||
|
|
||||||
const readErrorDetail = async (response: Response): Promise<string> => {
|
const readErrorDetail = async (response: Response): Promise<string> => {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
|
|
@ -1319,7 +1379,84 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
const seriesRef = useRef<CandlestickSeries | null>(null);
|
const seriesRef = useRef<CandlestickSeries | null>(null);
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectRef = useRef<number | null>(null);
|
const reconnectRef = useRef<number | null>(null);
|
||||||
|
const overlaySocketRef = useRef<WebSocket | null>(null);
|
||||||
|
const overlayReconnectRef = useRef<number | null>(null);
|
||||||
const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null);
|
const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null);
|
||||||
|
|
||||||
|
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const overlayCtxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
|
const overlayDataRef = useRef<EquityOverlayPoint[]>([]);
|
||||||
|
const overlayLiveRef = useRef<EquityOverlayPoint[]>([]);
|
||||||
|
const overlayLastFetchRef = useRef<{ startTs: number; endTs: number; ticker: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const overlayFetchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const overlayTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [overlayEnabled, setOverlayEnabled] = useState(true);
|
||||||
|
|
||||||
|
const drawOverlay = useCallback(
|
||||||
|
(points: EquityOverlayPoint[]) => {
|
||||||
|
const canvas = overlayCanvasRef.current;
|
||||||
|
const ctx = overlayCtxRef.current;
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!canvas || !ctx || !chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (!overlayEnabled || points.length === 0) {
|
||||||
|
canvas.style.opacity = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeScale = chart.timeScale();
|
||||||
|
if (!seriesRef.current) {
|
||||||
|
canvas.style.opacity = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = points.filter((point) => point.offExchangeFlag);
|
||||||
|
const sampled = sampleToLimit(filtered, 1400);
|
||||||
|
|
||||||
|
const maxRadius = 10;
|
||||||
|
const minRadius = 2;
|
||||||
|
const maxSize = Math.max(1, ...sampled.map((point) => point.size));
|
||||||
|
|
||||||
|
ctx.globalAlpha = 0.9;
|
||||||
|
ctx.fillStyle = "rgba(31, 74, 123, 0.55)";
|
||||||
|
ctx.strokeStyle = "rgba(31, 74, 123, 0.95)";
|
||||||
|
|
||||||
|
for (const point of sampled) {
|
||||||
|
const x = timeScale.timeToCoordinate(toChartTime(point.ts));
|
||||||
|
const y = seriesRef.current.priceToCoordinate(point.price);
|
||||||
|
if (x === null || y === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radius = clamp(
|
||||||
|
minRadius + (Math.sqrt(point.size) / Math.sqrt(maxSize)) * (maxRadius - minRadius),
|
||||||
|
minRadius,
|
||||||
|
maxRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
canvas.style.opacity = "1";
|
||||||
|
},
|
||||||
|
[overlayEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
|
}, [drawOverlay, ticker, intervalMs, mode]);
|
||||||
|
|
||||||
const replayBucket = useMemo(() => {
|
const replayBucket = useMemo(() => {
|
||||||
if (mode !== "replay" || replayTime === null) {
|
if (mode !== "replay" || replayTime === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1371,6 +1508,19 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const overlayCanvas = document.createElement("canvas");
|
||||||
|
overlayCanvas.width = Math.max(1, Math.floor(width));
|
||||||
|
overlayCanvas.height = Math.max(1, Math.floor(height));
|
||||||
|
overlayCanvas.style.position = "absolute";
|
||||||
|
overlayCanvas.style.inset = "0";
|
||||||
|
overlayCanvas.style.pointerEvents = "none";
|
||||||
|
overlayCanvas.style.zIndex = "2";
|
||||||
|
overlayCanvas.style.opacity = "0";
|
||||||
|
container.style.position = "relative";
|
||||||
|
container.appendChild(overlayCanvas);
|
||||||
|
overlayCanvasRef.current = overlayCanvas;
|
||||||
|
overlayCtxRef.current = overlayCanvas.getContext("2d");
|
||||||
|
|
||||||
const series = chart.addCandlestickSeries({
|
const series = chart.addCandlestickSeries({
|
||||||
upColor: "#2f6d4f",
|
upColor: "#2f6d4f",
|
||||||
downColor: "#c46f2a",
|
downColor: "#c46f2a",
|
||||||
|
|
@ -1390,10 +1540,18 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
}
|
}
|
||||||
const { width: nextWidth, height: nextHeight } = entry.contentRect;
|
const { width: nextWidth, height: nextHeight } = entry.contentRect;
|
||||||
if (Number.isFinite(nextWidth) && Number.isFinite(nextHeight)) {
|
if (Number.isFinite(nextWidth) && Number.isFinite(nextHeight)) {
|
||||||
|
const nextW = Math.max(1, Math.floor(nextWidth));
|
||||||
|
const nextH = Math.max(1, Math.floor(nextHeight));
|
||||||
chart.applyOptions({
|
chart.applyOptions({
|
||||||
width: Math.max(1, Math.floor(nextWidth)),
|
width: nextW,
|
||||||
height: Math.max(1, Math.floor(nextHeight))
|
height: nextH
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canvas = overlayCanvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.width = nextW;
|
||||||
|
canvas.height = nextH;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1404,6 +1562,9 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
chart.remove();
|
chart.remove();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
seriesRef.current = null;
|
seriesRef.current = null;
|
||||||
|
overlayCtxRef.current = null;
|
||||||
|
overlayCanvasRef.current?.remove();
|
||||||
|
overlayCanvasRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -1418,6 +1579,9 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
setLastUpdate(null);
|
setLastUpdate(null);
|
||||||
lastCandleRef.current = null;
|
lastCandleRef.current = null;
|
||||||
seriesRef.current.setData([]);
|
seriesRef.current.setData([]);
|
||||||
|
overlayDataRef.current = [];
|
||||||
|
overlayLiveRef.current = [];
|
||||||
|
overlayLastFetchRef.current = null;
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1428,6 +1592,9 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
setLastUpdate(null);
|
setLastUpdate(null);
|
||||||
lastCandleRef.current = null;
|
lastCandleRef.current = null;
|
||||||
seriesRef.current.setData([]);
|
seriesRef.current.setData([]);
|
||||||
|
overlayDataRef.current = [];
|
||||||
|
overlayLiveRef.current = [];
|
||||||
|
overlayLastFetchRef.current = null;
|
||||||
setStatus(mode === "live" ? "connecting" : "connected");
|
setStatus(mode === "live" ? "connecting" : "connected");
|
||||||
|
|
||||||
const fetchCandles = async () => {
|
const fetchCandles = async () => {
|
||||||
|
|
@ -1460,6 +1627,7 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
const chartData = sorted.map(toChartCandle);
|
const chartData = sorted.map(toChartCandle);
|
||||||
seriesRef.current.setData(chartData);
|
seriesRef.current.setData(chartData);
|
||||||
chartRef.current?.timeScale().fitContent();
|
chartRef.current?.timeScale().fitContent();
|
||||||
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
|
|
||||||
if (sorted.length > 0) {
|
if (sorted.length > 0) {
|
||||||
const last = sorted[sorted.length - 1];
|
const last = sorted[sorted.length - 1];
|
||||||
|
|
@ -1477,10 +1645,125 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const ensureOverlayListener = () => {
|
||||||
|
if (!chartRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = () => {
|
||||||
|
const combined = [...overlayDataRef.current, ...overlayLiveRef.current];
|
||||||
|
drawOverlay(combined);
|
||||||
|
scheduleOverlayFetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
chartRef.current.timeScale().subscribeVisibleTimeRangeChange(handler);
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.timeScale().unsubscribeVisibleTimeRangeChange(handler);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelOverlayFetch = () => {
|
||||||
|
if (overlayFetchAbortRef.current) {
|
||||||
|
overlayFetchAbortRef.current.abort();
|
||||||
|
overlayFetchAbortRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOverlayRange = async (startTs: number, endTs: number) => {
|
||||||
|
cancelOverlayFetch();
|
||||||
|
const abort = new AbortController();
|
||||||
|
overlayFetchAbortRef.current = abort;
|
||||||
|
|
||||||
|
const url = new URL(buildApiUrl("/prints/equities/range"));
|
||||||
|
url.searchParams.set("underlying_id", ticker);
|
||||||
|
url.searchParams.set("start_ts", Math.floor(startTs).toString());
|
||||||
|
url.searchParams.set("end_ts", Math.floor(endTs).toString());
|
||||||
|
url.searchParams.set("limit", "2500");
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), { signal: abort.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await readErrorDetail(response);
|
||||||
|
throw new Error(
|
||||||
|
`Equity range fetch failed (${response.status})${detail ? `: ${detail}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { data?: EquityPrint[] };
|
||||||
|
const prints = payload.data ?? [];
|
||||||
|
overlayDataRef.current = prints.map((print) => ({
|
||||||
|
ts: print.ts,
|
||||||
|
price: print.price,
|
||||||
|
size: print.size,
|
||||||
|
offExchangeFlag: print.offExchangeFlag
|
||||||
|
}));
|
||||||
|
overlayLiveRef.current = [];
|
||||||
|
overlayLastFetchRef.current = { startTs, endTs, ticker };
|
||||||
|
};
|
||||||
|
|
||||||
|
function scheduleOverlayFetch() {
|
||||||
|
if (overlayTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(overlayTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayTimerRef.current = window.setTimeout(() => {
|
||||||
|
if (!active || !chartRef.current || !seriesRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeScale = chartRef.current.timeScale();
|
||||||
|
const range = timeScale.getVisibleRange();
|
||||||
|
if (!range) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTs = chartTimeToMs(range.from);
|
||||||
|
const endTs = chartTimeToMs(range.to);
|
||||||
|
if (startTs === null || endTs === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const last = overlayLastFetchRef.current;
|
||||||
|
|
||||||
|
const needsFetch =
|
||||||
|
!last ||
|
||||||
|
last.ticker !== ticker ||
|
||||||
|
startTs < last.startTs ||
|
||||||
|
endTs > last.endTs ||
|
||||||
|
Math.abs(endTs - last.endTs) > intervalMs * 6;
|
||||||
|
|
||||||
|
if (!needsFetch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchOverlayRange(startTs, endTs)
|
||||||
|
.then(() => {
|
||||||
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn("Overlay fetch failed", error);
|
||||||
|
});
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlayUnsubscribe = ensureOverlayListener();
|
||||||
|
scheduleOverlayFetch();
|
||||||
|
|
||||||
void fetchCandles();
|
void fetchCandles();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
cancelOverlayFetch();
|
||||||
|
if (overlayTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(overlayTimerRef.current);
|
||||||
|
overlayTimerRef.current = null;
|
||||||
|
}
|
||||||
|
overlayUnsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [ready, ticker, intervalMs, mode, replayBucket, replayEndTs]);
|
}, [ready, ticker, intervalMs, mode, replayBucket, replayEndTs]);
|
||||||
|
|
||||||
|
|
@ -1493,6 +1776,15 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
window.clearTimeout(reconnectRef.current);
|
window.clearTimeout(reconnectRef.current);
|
||||||
reconnectRef.current = null;
|
reconnectRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overlaySocketRef.current) {
|
||||||
|
overlaySocketRef.current.close();
|
||||||
|
}
|
||||||
|
if (overlayReconnectRef.current !== null) {
|
||||||
|
window.clearTimeout(overlayReconnectRef.current);
|
||||||
|
overlayReconnectRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1545,6 +1837,7 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
lastCandleRef.current = { time: chartCandle.time, seq: candle.seq };
|
lastCandleRef.current = { time: chartCandle.time, seq: candle.seq };
|
||||||
setHasData(true);
|
setHasData(true);
|
||||||
setLastUpdate(candle.ingest_ts ?? candle.ts);
|
setLastUpdate(candle.ingest_ts ?? candle.ts);
|
||||||
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to parse candle payload", error);
|
console.warn("Failed to parse candle payload", error);
|
||||||
}
|
}
|
||||||
|
|
@ -1567,7 +1860,64 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const connectOverlay = () => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(buildWsUrl("/ws/equities"));
|
||||||
|
overlaySocketRef.current = socket;
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as StreamMessage<EquityPrint>;
|
||||||
|
if (!message || message.type !== "equity-print") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const print = message.payload;
|
||||||
|
if (print.underlying_id !== ticker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayLiveRef.current.push({
|
||||||
|
ts: print.ts,
|
||||||
|
price: print.price,
|
||||||
|
size: print.size,
|
||||||
|
offExchangeFlag: print.offExchangeFlag
|
||||||
|
});
|
||||||
|
|
||||||
|
if (overlayLiveRef.current.length > 1500) {
|
||||||
|
overlayLiveRef.current = overlayLiveRef.current.slice(-1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse equity print payload", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
overlayReconnectRef.current = window.setTimeout(connectOverlay, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
connectOverlay();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
|
|
@ -1578,8 +1928,16 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
if (socketRef.current) {
|
if (socketRef.current) {
|
||||||
socketRef.current.close();
|
socketRef.current.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overlayReconnectRef.current !== null) {
|
||||||
|
window.clearTimeout(overlayReconnectRef.current);
|
||||||
|
overlayReconnectRef.current = null;
|
||||||
|
}
|
||||||
|
if (overlaySocketRef.current) {
|
||||||
|
overlaySocketRef.current.close();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [ready, mode, ticker, intervalMs]);
|
}, [ready, mode, ticker, intervalMs, drawOverlay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartRef.current) {
|
if (!chartRef.current) {
|
||||||
|
|
@ -1610,6 +1968,14 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
<span className="chart-meta-time">
|
<span className="chart-meta-time">
|
||||||
{lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"}
|
{lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
className={`overlay-toggle${overlayEnabled ? " overlay-toggle-on" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOverlayEnabled((prev) => !prev)}
|
||||||
|
>
|
||||||
|
Off-Ex {overlayEnabled ? "On" : "Off"}
|
||||||
|
</button>
|
||||||
|
<span className="overlay-legend">Blue circles = off-exchange trades</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-surface" ref={containerRef} />
|
<div className="chart-surface" ref={containerRef} />
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue