LCOV - code coverage report
Current view: top level - dashql-app/src/demos/stock_analysis - stock_analysis_page.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 93.1 % 204 190
Test Date: 2026-11-06 19:30:32

            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;
        

Generated by: LCOV version 2.0-1