Line data Source code
1 : import * as React from 'react';
2 : import { Link } from 'react-router-dom';
3 :
4 : import { stringifyError } from '../../platform/logger/logger.js';
5 : import { useLogger } from '../../platform/logger/logger_provider.js';
6 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
7 : import { VegaLiteChart } from '../../ui/vegalite/vegalite_chart.js';
8 : import { arrowTableToRows } from '../../ui/vegalite/vegalite_rows.js';
9 : import { useDemoDataSource, type DemoDataSource } from '../shared/data_source.js';
10 : import { bindSqlTemplate } from '../shared/sql_template.js';
11 : import { useLiveQuery, type LiveQueryState } from '../shared/use_live_query.js';
12 : import { BinanceFeed } from './binance_feed.js';
13 : import { CoinbaseFeed } from './coinbase_feed.js';
14 : import { formatChange, formatPrice, formatRatio, LiveBoard, readBoardRows, type BoardRow } from './live_board.js';
15 : import { priceChartSpec, ROWS_DATASET, throughputChartSpec, WINDOW_END_SIGNAL, WINDOW_START_SIGNAL } from './stock_analysis_specs.js';
16 : import { SyntheticFeed } from './synthetic_feed.js';
17 : import { TickWriter, type TickFeed, type TickFeedStatus } from './tick_feed.js';
18 : import * as styles from './stock_analysis_page.module.css';
19 :
20 : import SCHEMA_SQL from './sql/schema.sql?raw';
21 : import LIVE_BOARD_SQL from './sql/live_board.sql?raw';
22 : import SYMBOL_CHART_SQL from './sql/symbol_chart.sql?raw';
23 : import THROUGHPUT_SQL from './sql/throughput.sql?raw';
24 : import STATS_SQL from './sql/stats.sql?raw';
25 :
26 1 : const LOG_CTX = 'stock_analysis';
27 :
28 : /// The time that the charts reach back
29 1 : const CHART_WINDOW_MS = 5 * 60 * 1000;
30 : /// The time that the throughput chart reaches back
31 1 : const THROUGHPUT_WINDOW_MS = 60 * 1000;
32 :
33 : /// The feeds of the HyperDB WASM of the page
34 : type FeedKind = 'synthetic' | 'coinbase' | 'binance';
35 1 : const FEEDS: { kind: FeedKind; label: string }[] = [
36 1 : { kind: 'synthetic', label: 'Synthetic' },
37 1 : { kind: 'coinbase', label: 'Coinbase' },
38 1 : { kind: 'binance', label: 'Binance' },
39 1 : ];
40 :
41 : /// The rates of the synthetic feed in ticks per second
42 1 : const RATES = [100, 1000, 10000];
43 :
44 : /// The ingest of a feed into the HyperDB WASM of the page
45 : interface IngestState {
46 : /// Does the table exist?
47 : ready: boolean;
48 : /// The state of the feed
49 : status: TickFeedStatus | null;
50 : /// The inserted ticks per second
51 : ticksPerSecond: number;
52 : /// The error of the latest failed statement
53 : error: string | null;
54 : }
55 :
56 1 : const NO_INGEST: IngestState = { ready: false, status: null, ticksPerSecond: 0, error: null };
57 :
58 : /// Feed ticks into the table of the HyperDB WASM of the page
59 1 : function useTickIngest(source: DemoDataSource | null, feedKind: FeedKind, rate: number): IngestState {
60 1 : const logger = useLogger();
61 1 : const [state, setState] = React.useState<IngestState>(NO_INGEST);
62 1 : const feed = React.useRef<TickFeed | null>(null);
63 : // The rate of a running synthetic feed changes without a restart
64 1 : const rateRef = React.useRef(rate);
65 1 : rateRef.current = rate;
66 : // The teardown of the previous feed, the next one starts after it so that no insert outlives the reset of the table
67 1 : const teardown = React.useRef<Promise<void>>(Promise.resolve());
68 :
69 1 : React.useEffect(() => {
70 1 : setState(NO_INGEST);
71 1 : if (source == null) return;
72 1 : let stopped = false;
73 1 : let writer: TickWriter | null = null;
74 1 : let rateTimer: ReturnType<typeof setInterval> | null = null;
75 1 : const connecting = (async () => {
76 1 : await teardown.current;
77 1 : if (stopped) return null;
78 1 : const conn = await source.database.connect();
79 1 : try {
80 : // Every feed starts with an empty table, the symbols of the feeds differ
81 1 : await conn.execute(SCHEMA_SQL);
82 1 : await conn.execute('delete from ticks');
83 1 : } catch (e) {
84 0 : await conn.close();
85 0 : throw e;
86 0 : }
87 1 : if (stopped) {
88 0 : await conn.close();
89 0 : return null;
90 0 : }
91 1 : writer = new TickWriter(conn, {
92 1 : onError: e => {
93 0 : logger.warn('Inserting ticks failed', { error: stringifyError(e) }, LOG_CTX);
94 0 : setState(s => ({ ...s, error: e instanceof Error ? e.message : String(e) }));
95 0 : },
96 1 : });
97 1 : const active = feedKind === 'synthetic' ? new SyntheticFeed({ ticksPerSecond: rateRef.current })
98 0 : : feedKind === 'coinbase' ? new CoinbaseFeed()
99 0 : : new BinanceFeed();
100 1 : feed.current = active;
101 1 : setState(s => ({ ...s, ready: true }));
102 1 : active.start(ticks => writer!.push(ticks), status => setState(s => ({ ...s, status })));
103 1 : let written = 0;
104 1 : rateTimer = setInterval(() => {
105 : // The updater runs later, it gets the rate and not the counters
106 1 : const ticksPerSecond = writer!.written - written;
107 1 : written = writer!.written;
108 1 : setState(s => ({ ...s, ticksPerSecond }));
109 1 : }, 1000);
110 1 : return conn;
111 1 : })();
112 1 : connecting.catch(e => {
113 0 : logger.error('Starting the feed failed', { error: stringifyError(e) }, LOG_CTX);
114 0 : setState(s => ({ ...s, error: e instanceof Error ? e.message : String(e) }));
115 1 : });
116 1 : return () => {
117 1 : stopped = true;
118 1 : teardown.current = (async () => {
119 1 : if (rateTimer != null) clearInterval(rateTimer);
120 1 : feed.current?.stop();
121 1 : feed.current = null;
122 1 : const conn = await connecting.catch(() => null);
123 1 : await writer?.stop();
124 1 : await conn?.close();
125 1 : })();
126 1 : };
127 1 : }, [source, feedKind, logger]);
128 :
129 1 : React.useEffect(() => {
130 1 : if (feed.current instanceof SyntheticFeed) feed.current.ticksPerSecond = rate;
131 1 : }, [rate]);
132 :
133 1 : return state;
134 1 : }
135 :
136 : /// The current time, advanced once per period
137 1 : function useNow(periodMs: number): number {
138 1 : const [now, setNow] = React.useState(() => Date.now());
139 1 : React.useEffect(() => {
140 1 : const timer = setInterval(() => setNow(Date.now()), periodMs);
141 1 : return () => clearInterval(timer);
142 1 : }, [periodMs]);
143 1 : return now;
144 1 : }
145 :
146 : /// The time of a query, e.g. 4ms
147 1 : function formatLatency(state: LiveQueryState): string | null {
148 1 : return state.latencyMs == null ? null : `${state.latencyMs < 10 ? state.latencyMs.toFixed(1) : Math.round(state.latencyMs)}ms`;
149 1 : }
150 :
151 : interface PanelProps {
152 : title: string;
153 : /// The query of the panel, its time is shown next to the title
154 : query: LiveQueryState;
155 : className?: string;
156 : children: React.ReactNode;
157 : 'data-testid'?: string;
158 : }
159 :
160 : /// A panel with the time of its query in the header and the error of the query in place of the content
161 1 : const Panel: React.FC<PanelProps> = (props: PanelProps) => (
162 1 : <section className={`${styles.panel} ${props.className ?? ''}`} aria-label={props.title} data-testid={props['data-testid']}>
163 1 : <header className={styles.panel_header}>
164 1 : <h2 className={styles.panel_title}>{props.title}</h2>
165 1 : <span className={styles.panel_latency} title="Time of the latest query">{formatLatency(props.query)}</span>
166 1 : </header>
167 1 : {props.query.error != null && <p className={styles.panel_error}>{props.query.error.message}</p>}
168 1 : <div className={styles.panel_body}>{props.children}</div>
169 1 : </section>
170 : );
171 :
172 : /// A value of the header
173 1 : const Stat: React.FC<{ label: string; value: React.ReactNode; 'data-testid'?: string }> = props => (
174 1 : <div className={styles.stat} data-testid={props['data-testid']}>
175 1 : <span className={styles.stat_label}>{props.label}</span>
176 1 : <span className={styles.stat_value}>{props.value}</span>
177 1 : </div>
178 : );
179 :
180 1 : const COUNT = new Intl.NumberFormat('en-US');
181 :
182 : interface CardProps {
183 : /// The name of the card
184 : title: string;
185 : /// The headline value
186 : value: string;
187 : /// The values next to the headline
188 : detail?: React.ReactNode;
189 : /// The query of the chart, its error is shown in place of the chart
190 : query: LiveQueryState;
191 : /// The chart
192 : children: React.ReactElement;
193 : 'data-testid'?: string;
194 : }
195 :
196 : /// A small card with a headline value and a chart under it
197 1 : const Card: React.FC<CardProps> = (props: CardProps) => (
198 1 : <section className={styles.card} aria-label={props.title} data-testid={props['data-testid']}>
199 1 : <header className={styles.card_header}>
200 1 : <h2 className={styles.card_title}>{props.title}</h2>
201 1 : </header>
202 1 : <div className={styles.card_values}>
203 1 : <span className={styles.card_value} data-testid="card-value">{props.value}</span>
204 1 : {props.detail != null && <span className={styles.card_detail}>{props.detail}</span>}
205 1 : </div>
206 1 : {props.query.error != null
207 0 : ? <p className={styles.card_error}>{props.query.error.message}</p>
208 1 : : props.children}
209 1 : </section>
210 : );
211 :
212 : /// The direction class of a change in a card
213 1 : function cardDirection(value: number | null): string | undefined {
214 1 : if (value == null || value === 0) return undefined;
215 1 : return value > 0 ? styles.up : styles.down;
216 1 : }
217 :
218 : /// Stock Analysis on Hyper: a live board of trades with charts of a symbol.
219 : /// The HyperDB WASM of the page is fed by a generator or the public feeds of Coinbase or Binance.
220 1 : export const StockAnalysisPage: React.FC = () => {
221 1 : const { source, error: sourceError } = useDemoDataSource();
222 1 : const [feedKind, setFeedKind] = React.useState<FeedKind>('synthetic');
223 1 : const [rate, setRate] = React.useState(1000);
224 1 : const ingest = useTickIngest(source, feedKind, rate);
225 :
226 : // The queries of the page wait for the table of the HyperDB WASM
227 1 : const database = source == null || !ingest.ready ? null : source.database;
228 1 : const board = useLiveQuery(database, LIVE_BOARD_SQL, 500);
229 1 : const throughput = useLiveQuery(database, THROUGHPUT_SQL, 1000);
230 1 : const stats = useLiveQuery(database, STATS_SQL, 1000);
231 :
232 1 : const rows = React.useMemo(() => board.table == null ? [] : readBoardRows(board.table), [board.table]);
233 1 : const [selected, setSelected] = React.useState<string | null>(null);
234 : // Without a selection, the chart shows the symbol with the most trades
235 1 : const busiest = rows.reduce<BoardRow | null>((best, row) => best == null || row.trades > best.trades ? row : best, null);
236 1 : const symbol = selected != null && rows.some(row => row.symbol === selected) ? selected : busiest?.symbol ?? null;
237 1 : const chartSql = React.useMemo(() => symbol == null ? null : bindSqlTemplate(SYMBOL_CHART_SQL, { symbol }), [symbol]);
238 1 : const chart = useLiveQuery(database, chartSql, 1000);
239 :
240 1 : const now = useNow(250);
241 1 : const priceData = React.useMemo(() => ({ [ROWS_DATASET]: chart.table == null ? [] : arrowTableToRows(chart.table) }), [chart.table]);
242 1 : const throughputData = React.useMemo(() => ({ [ROWS_DATASET]: throughput.table == null ? [] : arrowTableToRows(throughput.table) }), [throughput.table]);
243 1 : const priceSignals = React.useMemo(() => ({ [WINDOW_START_SIGNAL]: now - CHART_WINDOW_MS, [WINDOW_END_SIGNAL]: now }), [now]);
244 1 : const throughputSignals = React.useMemo(() => ({ [WINDOW_START_SIGNAL]: now - THROUGHPUT_WINDOW_MS, [WINDOW_END_SIGNAL]: now }), [now]);
245 :
246 : // The trades of the last full second, the current second is still filling up, and the mean of the window
247 1 : const trades = React.useMemo(() => {
248 1 : const rows = throughputData[ROWS_DATASET];
249 1 : if (throughput.table == null) return null;
250 1 : const lastSecond = Math.floor(Date.now() / 1000) * 1000 - 1000;
251 1 : const last = rows.find(row => row.t === lastSecond);
252 1 : const total = rows.reduce((sum, row) => sum + Number(row.trades), 0);
253 1 : return { last: last == null ? 0 : Number(last.trades), mean: total / (THROUGHPUT_WINDOW_MS / 1000) };
254 1 : }, [throughputData, throughput.table]);
255 :
256 1 : const statsRow = stats.table?.get(0);
257 1 : const selectedRow = rows.find(row => row.symbol === symbol) ?? null;
258 :
259 1 : return (
260 1 : <div className={styles.root}>
261 1 : <header className={styles.header}>
262 1 : <div className={styles.title_row}>
263 1 : <Link className={styles.back} to="/">Demos</Link>
264 1 : <h1 className={styles.title}>Stock Analysis</h1>
265 1 : <SegmentedControl aria-label="Feed" data-testid="feed-kind" size={SegmentedControlSize.Small} onChange={i => setFeedKind(FEEDS[i].kind)}>
266 1 : {FEEDS.map(f => <SegmentedControl.Button key={f.kind} selected={f.kind === feedKind}>{f.label}</SegmentedControl.Button>)}
267 1 : </SegmentedControl>
268 1 : {feedKind === 'synthetic' && (
269 1 : <SegmentedControl aria-label="Ticks per second" data-testid="feed-rate" size={SegmentedControlSize.Small} onChange={i => setRate(RATES[i])}>
270 1 : {RATES.map(r => <SegmentedControl.Button key={r} selected={r === rate}>{`${COUNT.format(r)}/s`}</SegmentedControl.Button>)}
271 1 : </SegmentedControl>
272 : )}
273 1 : </div>
274 1 : <div className={styles.stats}>
275 1 : <Stat label="Hyper" value={source?.version ?? '–'} />
276 1 : <Stat label="Rows" value={statsRow == null ? '–' : COUNT.format(Number(statsRow['rows']))} data-testid="stat-rows" />
277 1 : <Stat label="Symbols" value={statsRow == null ? '–' : COUNT.format(Number(statsRow['symbols']))} />
278 1 : <Stat label="Ingest" value={`${COUNT.format(ingest.ticksPerSecond)}/s`} data-testid="stat-ingest" />
279 1 : <Stat label="Feed" value={ingest.status == null ? '–' : `${ingest.status.state}${ingest.status.detail ? ` (${ingest.status.detail})` : ''}`} />
280 1 : </div>
281 1 : {(sourceError ?? ingest.error) != null && <p className={styles.error} role="alert">{sourceError ?? ingest.error}</p>}
282 1 : </header>
283 1 : <main className={styles.main}>
284 1 : <div className={styles.cards}>
285 1 : <Card
286 1 : title="Trades per second"
287 1 : value={trades == null ? '–' : COUNT.format(trades.last)}
288 1 : detail={trades == null ? null : `⌀ ${COUNT.format(Math.round(trades.mean))}/s over 60s`}
289 1 : query={throughput}
290 1 : data-testid="trades-card"
291 : >
292 1 : <VegaLiteChart className={styles.card_chart} spec={throughputChartSpec} datasets={throughputData} signals={throughputSignals} />
293 1 : </Card>
294 1 : <Card
295 1 : title={symbol ?? 'Ticker'}
296 1 : value={selectedRow == null ? '–' : formatPrice(selectedRow.last)}
297 1 : detail={selectedRow == null ? null : (
298 1 : <>
299 1 : <span className={cardDirection(selectedRow.change)}>{formatChange(selectedRow.change)} ({formatRatio(selectedRow.changeRatio)})</span>
300 1 : <span>Bid {formatPrice(selectedRow.bid)} · Ask {formatPrice(selectedRow.ask)}</span>
301 1 : </>
302 : )}
303 1 : query={chart}
304 1 : data-testid="ticker-card"
305 : >
306 1 : <VegaLiteChart className={styles.card_chart} spec={priceChartSpec} datasets={priceData} signals={priceSignals} />
307 1 : </Card>
308 1 : </div>
309 1 : <Panel className={styles.table_panel} title="Tickers, last 30 minutes" query={board} data-testid="board-panel">
310 1 : <LiveBoard rows={rows} selected={symbol} onSelect={setSelected} now={now} />
311 1 : </Panel>
312 1 : </main>
313 1 : </div>
314 : );
315 1 : };
316 :
317 : export default StockAnalysisPage;
|