Add classifier and dark markers to chart
This commit is contained in:
parent
4dd7b03810
commit
0f3e05085c
1 changed files with 257 additions and 2 deletions
|
|
@ -12,7 +12,7 @@ import type {
|
||||||
OptionNBBO,
|
OptionNBBO,
|
||||||
OptionPrint
|
OptionPrint
|
||||||
} from "@islandflow/types";
|
} from "@islandflow/types";
|
||||||
import { createChart, type IChartApi, type UTCTimestamp } from "lightweight-charts";
|
import { createChart, type IChartApi, type SeriesMarker, type UTCTimestamp } from "lightweight-charts";
|
||||||
|
|
||||||
const MAX_ITEMS = 500;
|
const MAX_ITEMS = 500;
|
||||||
const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS);
|
const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS);
|
||||||
|
|
@ -1371,9 +1371,26 @@ type CandleChartProps = {
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
mode: TapeMode;
|
mode: TapeMode;
|
||||||
replayTime?: number | null;
|
replayTime?: number | null;
|
||||||
|
classifierHits: ClassifierHitEvent[];
|
||||||
|
inferredDark: InferredDarkEvent[];
|
||||||
|
onClassifierHitClick: (hit: ClassifierHitEvent) => void;
|
||||||
|
onInferredDarkClick: (event: InferredDarkEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChartProps) => {
|
type MarkerAction =
|
||||||
|
| { kind: "hit"; hit: ClassifierHitEvent }
|
||||||
|
| { kind: "dark"; event: InferredDarkEvent };
|
||||||
|
|
||||||
|
const CandleChart = ({
|
||||||
|
ticker,
|
||||||
|
intervalMs,
|
||||||
|
mode,
|
||||||
|
replayTime = null,
|
||||||
|
classifierHits,
|
||||||
|
inferredDark,
|
||||||
|
onClassifierHitClick,
|
||||||
|
onInferredDarkClick
|
||||||
|
}: CandleChartProps) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const seriesRef = useRef<CandlestickSeries | null>(null);
|
const seriesRef = useRef<CandlestickSeries | null>(null);
|
||||||
|
|
@ -1383,6 +1400,11 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
const overlayReconnectRef = useRef<number | 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 markerLookupRef = useRef<Map<string, MarkerAction>>(new Map());
|
||||||
|
const [visibleRangeMs, setVisibleRangeMs] = useState<{ from: number; to: number } | null>(null);
|
||||||
|
const onHitClickRef = useRef(onClassifierHitClick);
|
||||||
|
const onDarkClickRef = useRef(onInferredDarkClick);
|
||||||
|
|
||||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const overlayCtxRef = useRef<CanvasRenderingContext2D | null>(null);
|
const overlayCtxRef = useRef<CanvasRenderingContext2D | null>(null);
|
||||||
const overlayDataRef = useRef<EquityOverlayPoint[]>([]);
|
const overlayDataRef = useRef<EquityOverlayPoint[]>([]);
|
||||||
|
|
@ -1457,6 +1479,132 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
drawOverlay([...overlayDataRef.current, ...overlayLiveRef.current]);
|
||||||
}, [drawOverlay, ticker, intervalMs, mode]);
|
}, [drawOverlay, ticker, intervalMs, mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onHitClickRef.current = onClassifierHitClick;
|
||||||
|
}, [onClassifierHitClick]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDarkClickRef.current = onInferredDarkClick;
|
||||||
|
}, [onInferredDarkClick]);
|
||||||
|
|
||||||
|
const markerBundle = useMemo(() => {
|
||||||
|
const lookup = new Map<string, MarkerAction>();
|
||||||
|
const markers: SeriesMarker<UTCTimestamp>[] = [];
|
||||||
|
|
||||||
|
if (!visibleRangeMs) {
|
||||||
|
return { markers, lookup };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = visibleRangeMs;
|
||||||
|
const inRangeHits = classifierHits
|
||||||
|
.filter((hit) => hit.source_ts >= from && hit.source_ts <= to)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const delta = a.source_ts - b.source_ts;
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
return a.seq - b.seq;
|
||||||
|
});
|
||||||
|
const inRangeDark = inferredDark
|
||||||
|
.filter((event) => event.source_ts >= from && event.source_ts <= to)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const delta = a.source_ts - b.source_ts;
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
return a.seq - b.seq;
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_HIT_MARKERS = 220;
|
||||||
|
const MAX_DARK_MARKERS = 120;
|
||||||
|
const MAX_TOTAL_MARKERS = 320;
|
||||||
|
|
||||||
|
const cappedHits =
|
||||||
|
inRangeHits.length > MAX_HIT_MARKERS
|
||||||
|
? inRangeHits.slice(inRangeHits.length - MAX_HIT_MARKERS)
|
||||||
|
: inRangeHits;
|
||||||
|
const cappedDark =
|
||||||
|
inRangeDark.length > MAX_DARK_MARKERS
|
||||||
|
? inRangeDark.slice(inRangeDark.length - MAX_DARK_MARKERS)
|
||||||
|
: inRangeDark;
|
||||||
|
|
||||||
|
for (const hit of cappedHits) {
|
||||||
|
const direction = normalizeDirection(hit.direction);
|
||||||
|
const markerId = `hit:${hit.trace_id}:${hit.seq}`;
|
||||||
|
lookup.set(markerId, { kind: "hit", hit });
|
||||||
|
|
||||||
|
markers.push({
|
||||||
|
id: markerId,
|
||||||
|
time: toChartTime(hit.source_ts),
|
||||||
|
position: direction === "bullish" ? "belowBar" : "aboveBar",
|
||||||
|
color:
|
||||||
|
direction === "bullish"
|
||||||
|
? "#2f6d4f"
|
||||||
|
: direction === "bearish"
|
||||||
|
? "#c46f2a"
|
||||||
|
: "rgba(111, 91, 57, 0.9)",
|
||||||
|
shape:
|
||||||
|
direction === "bullish"
|
||||||
|
? "arrowUp"
|
||||||
|
: direction === "bearish"
|
||||||
|
? "arrowDown"
|
||||||
|
: "circle",
|
||||||
|
text: hit.classifier_id ? hit.classifier_id.slice(0, 3).toUpperCase() : "H"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of cappedDark) {
|
||||||
|
const markerId = `dark:${event.trace_id}:${event.seq}`;
|
||||||
|
lookup.set(markerId, { kind: "dark", event });
|
||||||
|
markers.push({
|
||||||
|
id: markerId,
|
||||||
|
time: toChartTime(event.source_ts),
|
||||||
|
position: "aboveBar",
|
||||||
|
color: "rgba(31, 74, 123, 0.9)",
|
||||||
|
shape: "square",
|
||||||
|
text: "D"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markers.sort((a, b) => {
|
||||||
|
const delta = Number(a.time) - Number(b.time);
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
return String(a.id ?? "").localeCompare(String(b.id ?? ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
const cappedMarkers =
|
||||||
|
markers.length > MAX_TOTAL_MARKERS
|
||||||
|
? markers.slice(markers.length - MAX_TOTAL_MARKERS)
|
||||||
|
: markers;
|
||||||
|
|
||||||
|
if (cappedMarkers !== markers) {
|
||||||
|
const nextLookup = new Map<string, MarkerAction>();
|
||||||
|
for (const marker of cappedMarkers) {
|
||||||
|
const id = marker.id;
|
||||||
|
if (typeof id !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const action = lookup.get(id);
|
||||||
|
if (action) {
|
||||||
|
nextLookup.set(id, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { markers: cappedMarkers, lookup: nextLookup };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { markers: cappedMarkers, lookup };
|
||||||
|
}, [classifierHits, inferredDark, visibleRangeMs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seriesRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
markerLookupRef.current = markerBundle.lookup;
|
||||||
|
seriesRef.current.setMarkers(markerBundle.markers);
|
||||||
|
}, [markerBundle]);
|
||||||
|
|
||||||
const replayBucket = useMemo(() => {
|
const replayBucket = useMemo(() => {
|
||||||
if (mode !== "replay" || replayTime === null) {
|
if (mode !== "replay" || replayTime === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1533,6 +1681,47 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
seriesRef.current = series;
|
seriesRef.current = series;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
|
||||||
|
const timeScale = chart.timeScale();
|
||||||
|
const updateVisibleRange = () => {
|
||||||
|
const range = timeScale.getVisibleRange();
|
||||||
|
if (!range) {
|
||||||
|
setVisibleRangeMs(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = chartTimeToMs(range.from);
|
||||||
|
const to = chartTimeToMs(range.to);
|
||||||
|
if (from === null || to === null) {
|
||||||
|
setVisibleRangeMs(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibleRangeMs({
|
||||||
|
from: Math.min(from, to),
|
||||||
|
to: Math.max(from, to)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickHandler = (param: { hoveredObjectId?: unknown }) => {
|
||||||
|
const hovered = param.hoveredObjectId;
|
||||||
|
if (hovered === null || hovered === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = typeof hovered === "string" ? hovered : String(hovered);
|
||||||
|
const action = markerLookupRef.current.get(key);
|
||||||
|
if (!action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.kind === "hit") {
|
||||||
|
onHitClickRef.current(action.hit);
|
||||||
|
} else {
|
||||||
|
onDarkClickRef.current(action.event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVisibleRange();
|
||||||
|
timeScale.subscribeVisibleTimeRangeChange(updateVisibleRange);
|
||||||
|
chart.subscribeClick(clickHandler);
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|
@ -1559,6 +1748,8 @@ const CandleChart = ({ ticker, intervalMs, mode, replayTime = null }: CandleChar
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
timeScale.unsubscribeVisibleTimeRangeChange(updateVisibleRange);
|
||||||
|
chart.unsubscribeClick(clickHandler);
|
||||||
chart.remove();
|
chart.remove();
|
||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
seriesRef.current = null;
|
seriesRef.current = null;
|
||||||
|
|
@ -2607,6 +2798,14 @@ export default function HomePage() {
|
||||||
return extractUnderlying(match[1]);
|
return extractUnderlying(match[1]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const extractPacketIdFromClassifierHitTrace = useCallback((traceId: string): string | null => {
|
||||||
|
const idx = traceId.indexOf("flowpacket:");
|
||||||
|
if (idx < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return traceId.slice(idx);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const inferAlertUnderlying = useCallback(
|
const inferAlertUnderlying = useCallback(
|
||||||
(alert: AlertEvent): string | null => {
|
(alert: AlertEvent): string | null => {
|
||||||
const fromTrace = extractUnderlyingFromTrace(alert.trace_id);
|
const fromTrace = extractUnderlyingFromTrace(alert.trace_id);
|
||||||
|
|
@ -2699,6 +2898,58 @@ export default function HomePage() {
|
||||||
});
|
});
|
||||||
}, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]);
|
}, [classifierHits.items, extractUnderlyingFromTrace, matchesTicker, tickerSet]);
|
||||||
|
|
||||||
|
const chartClassifierHits = useMemo(() => {
|
||||||
|
const desired = chartTicker.toUpperCase();
|
||||||
|
return classifierHits.items
|
||||||
|
.filter((hit) => extractUnderlyingFromTrace(hit.trace_id) === desired)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const delta = a.source_ts - b.source_ts;
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
return a.seq - b.seq;
|
||||||
|
});
|
||||||
|
}, [chartTicker, classifierHits.items, extractUnderlyingFromTrace]);
|
||||||
|
|
||||||
|
const chartInferredDark = useMemo(() => {
|
||||||
|
const desired = chartTicker.toUpperCase();
|
||||||
|
return inferredDark.items
|
||||||
|
.filter((event) => inferDarkUnderlying(event, equityPrintMap, equityJoinMap) === desired)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const delta = a.source_ts - b.source_ts;
|
||||||
|
if (delta !== 0) {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
return a.seq - b.seq;
|
||||||
|
});
|
||||||
|
}, [chartTicker, inferredDark.items, equityJoinMap, equityPrintMap]);
|
||||||
|
|
||||||
|
const handleClassifierMarkerClick = useCallback(
|
||||||
|
(hit: ClassifierHitEvent) => {
|
||||||
|
const packetId = extractPacketIdFromClassifierHitTrace(hit.trace_id);
|
||||||
|
if (!packetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredTrace = `alert:${packetId}`;
|
||||||
|
const alert = alerts.items.find(
|
||||||
|
(item) => item.trace_id === desiredTrace || item.evidence_refs[0] === packetId
|
||||||
|
);
|
||||||
|
if (!alert) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDarkEvent(null);
|
||||||
|
setSelectedAlert(alert);
|
||||||
|
},
|
||||||
|
[alerts.items, extractPacketIdFromClassifierHitTrace]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDarkMarkerClick = useCallback((event: InferredDarkEvent) => {
|
||||||
|
setSelectedAlert(null);
|
||||||
|
setSelectedDarkEvent(event);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const lastSeen = useMemo(() => {
|
const lastSeen = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
options.lastUpdate,
|
options.lastUpdate,
|
||||||
|
|
@ -2803,6 +3054,10 @@ export default function HomePage() {
|
||||||
intervalMs={chartIntervalMs}
|
intervalMs={chartIntervalMs}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
replayTime={equities.replayTime}
|
replayTime={equities.replayTime}
|
||||||
|
classifierHits={chartClassifierHits}
|
||||||
|
inferredDark={chartInferredDark}
|
||||||
|
onClassifierHitClick={handleClassifierMarkerClick}
|
||||||
|
onInferredDarkClick={handleDarkMarkerClick}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue