Add equity candle chart to web UI
This commit is contained in:
parent
a87df21baa
commit
c9be8e8490
4 changed files with 480 additions and 0 deletions
|
|
@ -180,6 +180,10 @@ h1 {
|
||||||
grid-column: span 4;
|
grid-column: span 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-chart {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
|
||||||
.card-equities {
|
.card-equities {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
@ -274,6 +278,108 @@ h1 {
|
||||||
flex: 0 0 auto;
|
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 {
|
.tape-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -854,6 +960,7 @@ h1 {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-chart,
|
||||||
.card-options,
|
.card-options,
|
||||||
.card-equities,
|
.card-equities,
|
||||||
.card-flow,
|
.card-flow,
|
||||||
|
|
@ -869,6 +976,7 @@ h1 {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-chart,
|
||||||
.card-options,
|
.card-options,
|
||||||
.card-equities,
|
.card-equities,
|
||||||
.card-flow,
|
.card-flow,
|
||||||
|
|
@ -930,4 +1038,8 @@ h1 {
|
||||||
.card-dark {
|
.card-dark {
|
||||||
height: 780px;
|
height: 780px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-surface {
|
||||||
|
height: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
||||||
import type {
|
import type {
|
||||||
AlertEvent,
|
AlertEvent,
|
||||||
ClassifierHitEvent,
|
ClassifierHitEvent,
|
||||||
|
EquityCandle,
|
||||||
EquityPrint,
|
EquityPrint,
|
||||||
EquityPrintJoin,
|
EquityPrintJoin,
|
||||||
FlowPacket,
|
FlowPacket,
|
||||||
|
|
@ -11,12 +12,56 @@ import type {
|
||||||
OptionNBBO,
|
OptionNBBO,
|
||||||
OptionPrint
|
OptionPrint
|
||||||
} from "@islandflow/types";
|
} from "@islandflow/types";
|
||||||
|
import { createChart, type IChartApi, 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);
|
||||||
const NBBO_MAX_AGE_MS_SAFE =
|
const NBBO_MAX_AGE_MS_SAFE =
|
||||||
Number.isFinite(NBBO_MAX_AGE_MS) && NBBO_MAX_AGE_MS > 0 ? NBBO_MAX_AGE_MS : 1000;
|
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 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<IChartApi["addCandlestickSeries"]>;
|
||||||
|
|
||||||
|
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";
|
type WsStatus = "connecting" | "connected" | "disconnected";
|
||||||
|
|
||||||
|
|
@ -26,6 +71,7 @@ type MessageType =
|
||||||
| "option-print"
|
| "option-print"
|
||||||
| "option-nbbo"
|
| "option-nbbo"
|
||||||
| "equity-print"
|
| "equity-print"
|
||||||
|
| "equity-candle"
|
||||||
| "equity-join"
|
| "equity-join"
|
||||||
| "flow-packet"
|
| "flow-packet"
|
||||||
| "inferred-dark"
|
| "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<HTMLDivElement | null>(null);
|
||||||
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
|
const seriesRef = useRef<CandlestickSeries | null>(null);
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectRef = useRef<number | null>(null);
|
||||||
|
const lastCandleRef = useRef<{ time: UTCTimestamp; seq: number } | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [status, setStatus] = useState<WsStatus>(mode === "live" ? "connecting" : "connected");
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<number | null>(null);
|
||||||
|
const [hasData, setHasData] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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<EquityCandle>;
|
||||||
|
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 (
|
||||||
|
<div className="chart-panel">
|
||||||
|
<div className="chart-meta">
|
||||||
|
<div className={`chart-status chart-status-${status}`}>
|
||||||
|
<span className="chart-dot" />
|
||||||
|
<span>{statusText}</span>
|
||||||
|
</div>
|
||||||
|
<span className="chart-meta-time">
|
||||||
|
{lastUpdate ? `Updated ${formatTime(lastUpdate)}` : "Waiting for data"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="chart-surface" ref={containerRef} />
|
||||||
|
{error ? (
|
||||||
|
<div className="empty chart-empty">Chart error: {error}</div>
|
||||||
|
) : !hasData ? (
|
||||||
|
<div className="empty chart-empty">
|
||||||
|
{mode === "live"
|
||||||
|
? "No candles yet. Start candles service."
|
||||||
|
: "No candles for this replay window."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type AlertSeverityStripProps = {
|
type AlertSeverityStripProps = {
|
||||||
alerts: AlertEvent[];
|
alerts: AlertEvent[];
|
||||||
};
|
};
|
||||||
|
|
@ -1503,6 +1832,7 @@ export default function HomePage() {
|
||||||
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
|
const [selectedAlert, setSelectedAlert] = useState<AlertEvent | null>(null);
|
||||||
const [selectedDarkEvent, setSelectedDarkEvent] = useState<InferredDarkEvent | null>(null);
|
const [selectedDarkEvent, setSelectedDarkEvent] = useState<InferredDarkEvent | null>(null);
|
||||||
const [filterInput, setFilterInput] = useState<string>("");
|
const [filterInput, setFilterInput] = useState<string>("");
|
||||||
|
const [chartIntervalMs, setChartIntervalMs] = useState<number>(CANDLE_INTERVALS[0].ms);
|
||||||
const optionsScroll = useListScroll();
|
const optionsScroll = useListScroll();
|
||||||
const equitiesScroll = useListScroll();
|
const equitiesScroll = useListScroll();
|
||||||
const flowScroll = useListScroll();
|
const flowScroll = useListScroll();
|
||||||
|
|
@ -1635,6 +1965,7 @@ export default function HomePage() {
|
||||||
}, [filterInput]);
|
}, [filterInput]);
|
||||||
|
|
||||||
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
|
const tickerSet = useMemo(() => new Set(activeTickers), [activeTickers]);
|
||||||
|
const chartTicker = useMemo(() => activeTickers[0] ?? "SPY", [activeTickers]);
|
||||||
|
|
||||||
const nbboMap = useMemo(() => {
|
const nbboMap = useMemo(() => {
|
||||||
const map = new Map<string, OptionNBBO>();
|
const map = new Map<string, OptionNBBO>();
|
||||||
|
|
@ -1921,6 +2252,37 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cards">
|
<div className="cards">
|
||||||
|
<section className="card card-chart">
|
||||||
|
<div className="card-header">
|
||||||
|
<div>
|
||||||
|
<h2>Equity Chart</h2>
|
||||||
|
<p className="card-subtitle">
|
||||||
|
Server-built {formatIntervalLabel(chartIntervalMs)} candles for {chartTicker}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-controls">
|
||||||
|
<div className="chart-intervals">
|
||||||
|
{CANDLE_INTERVALS.map((interval) => (
|
||||||
|
<button
|
||||||
|
key={interval.ms}
|
||||||
|
className={`interval-button${interval.ms === chartIntervalMs ? " active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartIntervalMs(interval.ms)}
|
||||||
|
>
|
||||||
|
{interval.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{activeTickers.length > 1 ? (
|
||||||
|
<span className="chart-hint">Charting first of {activeTickers.length} tickers</span>
|
||||||
|
) : (
|
||||||
|
<span className="chart-hint">Charting {chartTicker}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CandleChart ticker={chartTicker} intervalMs={chartIntervalMs} mode={mode} />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card card-options">
|
<section className="card card-options">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@islandflow/types": "workspace:*",
|
"@islandflow/types": "workspace:*",
|
||||||
|
"lightweight-charts": "^4.2.0",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|
|
||||||
5
bun.lock
5
bun.lock
|
|
@ -9,6 +9,7 @@
|
||||||
"name": "@islandflow/web",
|
"name": "@islandflow/web",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@islandflow/types": "workspace:*",
|
"@islandflow/types": "workspace:*",
|
||||||
|
"lightweight-charts": "^4.2.0",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|
@ -205,10 +206,14 @@
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue