diff --git a/.changeset/fancy-times-jog.md b/.changeset/fancy-times-jog.md new file mode 100644 index 0000000000..add52e7044 --- /dev/null +++ b/.changeset/fancy-times-jog.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-table': patch +--- + +improve flexRender instance reuse and reduce adapter allocations diff --git a/examples/alpine/realtime-trading/.gitignore b/examples/alpine/realtime-trading/.gitignore new file mode 100644 index 0000000000..1502ec233f --- /dev/null +++ b/examples/alpine/realtime-trading/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules + diff --git a/examples/alpine/realtime-trading/README.md b/examples/alpine/realtime-trading/README.md new file mode 100644 index 0000000000..1fcc1bb50f --- /dev/null +++ b/examples/alpine/realtime-trading/README.md @@ -0,0 +1,150 @@ +# Alpine realtime trading benchmark + +This standalone example exercises the current TanStack Alpine Table adapter +with a high-frequency worker feed, immutable snapshots, interactive columns, +custom quote elements, Virtual Core, and browser diagnostics. It is a +repeatable UI stress workload, not an exchange or network benchmark. + +## Run and verify + +```bash +pnpm --dir examples/alpine/realtime-trading dev +``` + +Open `http://localhost:7784`. + +```bash +pnpm --dir examples/alpine/realtime-trading test:types +pnpm --dir examples/alpine/realtime-trading lint +pnpm --dir examples/alpine/realtime-trading build +pnpm --dir examples/alpine/realtime-trading test:e2e +``` + +Use a production build for performance recordings. + +## Structure and ownership + +| Path | Responsibility | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `src/feed/` | Market model, instrument universe, feed config, immutable update helpers, and direct TanStack atom controller. | +| `src/feed/worker/` | Typed protocol, deterministic engine, and module worker. | +| `src/benchmark/` | Browser monitor and benchmark controller. | +| `src/shell/configurator-options.ts` | Declarative select/range options used by the sidebar. | +| `src/table/table-config/` | Grouped columns and custom quote elements. | +| `src/table/` | Shared interaction helpers and virtualization constants. | +| `src/main.ts` | Alpine component factory and the imperative bridges to Store, Table, Virtual Core, observers, and DOM layout. | +| `index.html` | Declarative full-viewport shell, table template, controls, diagnostics, and status bar. | + +Alpine keeps the markup declarative in `index.html`; `tradingApp` owns only the +runtime coordination that cannot live in HTML. Feed and benchmark remain +separate controllers. Direct feed atoms are bridged into an Alpine reactive view +object individually: the high-frequency `quotes` atom is independent from +status and configuration atoms. Selected symbol and renderer mode are also +dedicated atoms. Every subscription, observer, virtualizer mount, controller, +and worker has explicit cleanup registered by `init()`/`destroy()`. + +## Feed and worker pipeline + +Defaults are 100 instruments, 10K generated samples/s, 20 ms delivery, enabled +intraday charts, and 16 ms chart sampling. + +Mutable quotes stay in the worker. A deterministic 16 ms budget loop generates +samples, a row-indexed `Map` coalesces repeated instrument changes, and a +separate timer publishes the latest unique rows. The main thread creates a new +outer array and only new changed rows. Unchanged rows and unsampled history +arrays retain identity. Session IDs reject late messages after resets or row +count changes. + +- **Synthetic quote workload** is generated worker samples/s, not Alpine DOM + updates or worker messages. +- **Worker delivery interval** controls coalesced message cadence; 20 ms targets + about 50 messages/s. +- **Row updates** counts unique immutable row objects applied. +- **Message samples** is the generated work represented by the latest batch. + +The 25K burst intentionally creates and flushes one expensive message. +Worker delivery resembles an upstream stream but does not include a network. + +## Alpine table architecture + +The table has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart. It supports sorting/filtering, on-change resizing, +double-click reset, drag column ordering, CSS hover, row selection, drag cell +ranges, keyboard navigation, and Price/Move/Percent/Sparkline custom elements. + +Stable instrument IDs back `getRowId`. The local `rows()` function caches the +row model by feed-array identity and table-state version so repeated Alpine +template reads do not call `getRowModel()` again during the same state. One +`TradingGridPointerController` handles all body input using `composedPath()` and +data attributes instead of per-cell listeners. + +Column widths are CSS custom properties updated only by sizing/order +subscriptions. `ResizeObserver` performs initial fit and stops after manual +resize. Component A/B swapping is an explicit lifecycle stress mode; stable +rendering is the realistic default. + +## Virtualization + +- Below 200 rows, automatic mode chooses Full DOM, but Virtual remains + selectable. +- From 200 through 1,499 rows, automatic mode chooses TanStack Virtual and Full + DOM remains selectable. +- At 1,500 rows or more, Virtual is forced and the control is locked. + +`main.ts` owns one `@tanstack/virtual-core` instance, updates its count/options, +and increments a narrow Alpine virtual version when its range changes. It uses +32 px estimates, 10-row overscan, row IDs as keys, transformed rows, and a body +spacer. The footer reads the instance range. Both modes apply +`content-visibility: auto`; Full DOM still creates all rows/cells. + +## Performance decisions + +- worker generation and coalescing before main-thread delivery; +- structural sharing for unchanged rows and histories; +- cached row-model reads keyed by actual dependencies; +- stable table/virtual row IDs; +- dedicated reactive versions for table and virtualizer changes; +- one delegated grid interaction controller and CSS hover; +- CSS variables for column sizes; +- configurable chart cadence and opt-in component churn; +- virtual mounting for large row sets; +- explicit lifecycle cleanup and low-frequency metrics publication. + +The immutable outer array changes by design. Stable inner references reduce +cell work, but sorting/filtering can still require a fresh row-model pass. + +## Diagnostics and interpretation + +The sidebar keeps four cross-framework health signals prominent: estimated +frame callbacks, average snapshot-to-DOM-commit latency, cumulative long +animation frames, and changed-row/snapshot throughput. The remaining counters +stay in Diagnostics so they do not look like equally important scores. + +`AVG COMMIT` is not the duration of an Alpine render function and it does not +include the browser's later layout or paint. It starts when a new immutable +snapshot is applied and ends in one coalesced `Alpine.nextTick()` callback, +after Alpine has flushed the corresponding DOM work. The average uses a rolling +3-second window; diagnostic p95/max use 10 seconds. If several worker +messages arrive before one Alpine flush, they intentionally produce one commit +sample from the earliest pending snapshot. + +The frame-rate estimate counts standard `requestAnimationFrame` callbacks over +one second; it is refresh-rate dependent and is not GPU/compositor FPS. +`Observed MutationRecords/s` is observer delivery count, not browser DOM +operations. It tracks text, child-list, class, and style changes while excluding +selection data attributes to reduce noise. The observer itself still adds work +at very high mutation rates. Heap is Chrome-only, current and GC-sensitive; +temporary growth is not proof of a leak without post-GC retention. React Scan is +not part of these shared measurements. User Timing commit and row-model entries +are sampled once every 20 candidates; the numeric counters remain exact, while +the Performance timeline avoids one retained entry per hot-path execution. + +## Standalone policy + +This folder intentionally contains its own instruments, feed, worker, +benchmark, shell markup, styles, and table code. It can run independently or be +copied to StackBlitz, so common source and README sections are duplicated across +adapters by design. + +The workspace resolves the pinned `@tanstack/alpine-table` dependency to the +local adapter package while keeping the manifest release-like. diff --git a/examples/alpine/realtime-trading/index.html b/examples/alpine/realtime-trading/index.html new file mode 100644 index 0000000000..bb7d47afb5 --- /dev/null +++ b/examples/alpine/realtime-trading/index.html @@ -0,0 +1,404 @@ + + + + + + TanStack Alpine Table · Realtime Trading + + +
+
+
+
MARKET MONITOR
+
+ +
+
+ +
+
+
+ + + + + + + +
+
+
+ +
+
+ + +
+ + + diff --git a/examples/alpine/realtime-trading/package.json b/examples/alpine/realtime-trading/package.json new file mode 100644 index 0000000000..e0f67261e4 --- /dev/null +++ b/examples/alpine/realtime-trading/package.json @@ -0,0 +1,25 @@ +{ + "name": "tanstack-alpine-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/alpine-table": "9.1.2", + "@tanstack/store": "^0.11.0", + "@tanstack/virtual-core": "^3.13.35", + "alpinejs": "^3.15.12" + }, + "devDependencies": { + "@types/alpinejs": "^3.13.11", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/alpine/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/alpine/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..1bf9cdbc8c --- /dev/null +++ b/examples/alpine/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,436 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCandidateCount: 0 } +const userTimingSamplingInterval = 20 + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCandidateCount++ + if (userTiming.measureCandidateCount % userTimingSamplingInterval !== 0) + return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + pendingMutationStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markCommitPending(): void { + this.#runtime.pendingMutationStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingMutationStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingMutationStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingMutationStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingMutationStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingMutationStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/alpine/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/alpine/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..12e65e30ea --- /dev/null +++ b/examples/alpine/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/alpine/realtime-trading/src/feed/feed-sample-rates.ts b/examples/alpine/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/alpine/realtime-trading/src/feed/market-data.ts b/examples/alpine/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/alpine/realtime-trading/src/feed/market-feed-config.ts b/examples/alpine/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/alpine/realtime-trading/src/feed/market-feed-controller.ts b/examples/alpine/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..40f523562d --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/alpine/realtime-trading/src/feed/market-instruments.ts b/examples/alpine/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/alpine/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/alpine/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/alpine/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/alpine/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/alpine/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/alpine/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/alpine/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/alpine/realtime-trading/src/index.css b/examples/alpine/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/alpine/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/alpine/realtime-trading/src/main.ts b/examples/alpine/realtime-trading/src/main.ts new file mode 100644 index 0000000000..4b612dbc10 --- /dev/null +++ b/examples/alpine/realtime-trading/src/main.ts @@ -0,0 +1,602 @@ +import Alpine from 'alpinejs' +import { + FlexRender, + createFilteredRowModel, + createSortedRowModel, + createTable, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, +} from '@tanstack/alpine-table' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, +} from '@tanstack/virtual-core' +import { TradingBenchmarkController } from './benchmark/trading-benchmark-controller' +import { MarketFeedController } from './feed/market-feed-controller' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from './feed/feed-sample-rates' +import { configuratorOptions } from './shell/configurator-options' +import { + createTradingColumns, + readMeasuredRows, +} from './table/table-config/trading-columns' +import { + TradingGridPointerController, + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from './table/table-interactions' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, +} from './table/trading-row-virtualizer' +import './table/table-config/quote-cells' +import './index.css' +import type { AlpineTable } from '@tanstack/alpine-table' +import type { VirtualItem } from '@tanstack/virtual-core' +import type { MarketQuote } from './feed/market-data' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) +type Table = AlpineTable +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const ms = (number: number) => `${number.toFixed(2)} ms` + +Alpine.data('tradingApp', () => { + const feed = new MarketFeedController() + const controller = new TradingBenchmarkController(feed) + const local = Alpine.reactive({ + feed: { + workerReady: feed.workerReady.get(), + running: feed.running.get(), + instrumentCount: feed.instrumentCount.get(), + targetTicksPerSecond: feed.targetTicksPerSecond.get(), + publishIntervalMs: feed.publishIntervalMs.get(), + updateSparklines: feed.updateSparklines.get(), + sparklineSampleIntervalMs: feed.sparklineSampleIntervalMs.get(), + quotes: feed.quotes.get(), + }, + benchmark: controller.store.get(), + selectedSymbol: controller.renderAtoms.selectedSymbol.get(), + rendererMode: controller.renderAtoms.rendererMode.get(), + sidebarOpen: true, + virtualVersion: 0, + tableVersion: 0, + }) + const columns = createTradingColumns( + () => local.rendererMode, + ) + const table = createTable( + { + key: 'alpine-realtime-trading', + features, + columns, + get data() { + return local.feed.quotes + }, + getRowId: (row: MarketQuote) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }, + (state) => ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnOrder: state.columnOrder, + rowSelection: state.rowSelection, + cellSelection: state.cellSelection, + }), + ) + const pointer = new TradingGridPointerController() + const runtime = { + virtualizer: null as Virtualizer< + HTMLDivElement, + HTMLTableRowElement + > | null, + cleanups: [] as Array<() => void>, + manuallyResized: false, + dragColumnId: null as string | null, + dragSource: null as HTMLTableCellElement | null, + dragTarget: null as HTMLTableCellElement | null, + cachedRows: [] as ReturnType['rows'], + cachedFeed: null as Array | null, + cachedTableVersion: -1, + lastRenderedRowCount: -1, + domCommitScheduled: false, + } + const rows = () => { + if ( + runtime.cachedFeed !== local.feed.quotes || + runtime.cachedTableVersion !== local.tableVersion + ) { + runtime.cachedFeed = local.feed.quotes + runtime.cachedTableVersion = local.tableVersion + runtime.cachedRows = readMeasuredRows(() => table.getRowModel().rows) + } + return runtime.cachedRows + } + const virtualMode = () => + resolveVirtualScrollMode( + local.benchmark.requestedVirtualScrollMode, + local.feed.instrumentCount, + ) + const syncVirtualizer = () => { + const virtualizer = runtime.virtualizer + const currentRows = rows() + if (!virtualizer) return + virtualizer.setOptions({ + ...virtualizer.options, + count: currentRows.length, + enabled: virtualMode() === 'tanstack', + getItemKey: (index) => currentRows[index]?.id ?? index, + }) + virtualizer._willUpdate() + } + const renderRows = (): Array<{ + row: (typeof runtime.cachedRows)[number] + virtual: VirtualItem | null + }> => { + void local.virtualVersion + syncVirtualizer() + const currentRows = rows() + const result = + virtualMode() === 'tanstack' && runtime.virtualizer + ? runtime.virtualizer + .getVirtualItems() + .map((item) => ({ row: currentRows[item.index], virtual: item })) + : currentRows.map((row) => ({ row, virtual: null })) + if (runtime.lastRenderedRowCount !== result.length) { + runtime.lastRenderedRowCount = result.length + queueMicrotask(() => + controller.actions.setRenderedRowCount(runtime.lastRenderedRowCount), + ) + } + return result + } + const writeSizes = (tableElement: HTMLTableElement) => { + for (const header of table.getFlatHeaders()) { + tableElement.style.setProperty( + `--header-${header.id}-size`, + String(header.getSize()), + ) + tableElement.style.setProperty( + `--col-${header.column.id}-size`, + String(header.column.getSize()), + ) + } + tableElement.style.width = `${table.getTotalSize()}px` + } + const scheduleDomCommit = () => { + if (runtime.domCommitScheduled) return + + runtime.domCommitScheduled = true + Alpine.nextTick(() => { + runtime.domCommitScheduled = false + feed.completeRender() + }) + } + + return { + table, + FlexRender, + local, + feed, + controller, + feedSampleRateOptions, + configuratorOptions, + init(this: { $refs: Record }) { + const scroll = this.$refs.scroll as HTMLDivElement + const tableElement = this.$refs.table as HTMLTableElement + runtime.virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => scroll, + estimateSize: () => TRADING_ROW_HEIGHT, + overscan: TRADING_ROW_OVERSCAN, + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + onChange: () => { + local.virtualVersion++ + }, + }) + const stopVirtualizer = runtime.virtualizer._didMount() + const resizeObserver = new ResizeObserver(() => { + if (runtime.manuallyResized) return + const width = table.getTotalSize() + if (scroll.clientWidth <= width + 1 || width <= 0) return + const ratio = scroll.clientWidth / width + table.setColumnSizing( + Object.fromEntries( + table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + }) + resizeObserver.observe(scroll) + const mutationObserver = new MutationObserver((records) => + controller.monitor.recordDomMutations(records.length), + ) + mutationObserver.observe(tableElement.tBodies[0], { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + const subscriptions = [ + feed.workerReady.subscribe((value) => { + local.feed.workerReady = value + }), + feed.running.subscribe((value) => { + local.feed.running = value + }), + feed.instrumentCount.subscribe((value) => { + local.feed.instrumentCount = value + }), + feed.targetTicksPerSecond.subscribe((value) => { + local.feed.targetTicksPerSecond = value + }), + feed.publishIntervalMs.subscribe((value) => { + local.feed.publishIntervalMs = value + }), + feed.updateSparklines.subscribe((value) => { + local.feed.updateSparklines = value + }), + feed.sparklineSampleIntervalMs.subscribe((value) => { + local.feed.sparklineSampleIntervalMs = value + }), + feed.quotes.subscribe((quotes) => { + local.feed.quotes = quotes + scheduleDomCommit() + }), + controller.store.subscribe((state) => { + local.benchmark = state + }), + controller.renderAtoms.selectedSymbol.subscribe((symbol) => { + local.selectedSymbol = symbol + }), + controller.renderAtoms.rendererMode.subscribe((mode) => { + local.rendererMode = mode + local.tableVersion++ + }), + table.store.subscribe(() => { + local.tableVersion++ + }), + table.atoms.columnSizing.subscribe(() => writeSizes(tableElement)), + table.atoms.columnOrder.subscribe(() => writeSizes(tableElement)), + table.atoms.columnResizing.subscribe((state) => { + if (state.isResizingColumn !== false) runtime.manuallyResized = true + }), + ] + const stopFeed = feed.start() + const stopBenchmark = controller.start() + runtime.cleanups.push( + stopVirtualizer, + stopFeed, + stopBenchmark, + () => resizeObserver.disconnect(), + () => mutationObserver.disconnect(), + ...subscriptions.map( + (subscription) => () => subscription.unsubscribe(), + ), + ) + Alpine.nextTick(() => writeSizes(tableElement)) + scheduleDomCommit() + }, + destroy() { + for (const cleanup of runtime.cleanups) cleanup() + runtime.cleanups.length = 0 + }, + rows, + renderRows, + virtualMode, + tableHeight() { + void local.virtualVersion + return ( + runtime.virtualizer?.getTotalSize() ?? + rows().length * TRADING_ROW_HEIGHT + ) + }, + visibleRangeText() { + void local.virtualVersion + const range = runtime.virtualizer?.range + const currentRows = rows() + if (!range || virtualMode() !== 'tanstack' || !currentRows.length) + return 'Current · rows —' + return `Current · rows ${Math.min(range.startIndex, currentRows.length - 1)}..${Math.min(range.endIndex, currentRows.length - 1)}` + }, + toggleSidebar() { + local.sidebarOpen = !local.sidebarOpen + }, + feedStatus() { + return !local.feed.workerReady + ? 'FEED CONNECTING' + : local.feed.running + ? 'FEED LIVE' + : 'FEED PAUSED' + }, + setInstrumentCount(event: Event) { + controller.actions.resetViewState() + feed.actions.setInstrumentCount( + Number((event.target as HTMLSelectElement).value), + ) + }, + sampleRateIndex() { + return feedSampleRateIndex(local.feed.targetTicksPerSecond) + }, + setSampleRate(event: Event) { + feed.actions.setTargetRate( + feedSampleRateAt(Number((event.target as HTMLInputElement).value)), + ) + }, + setPublishInterval(event: Event) { + feed.actions.setPublishInterval( + Number((event.target as HTMLSelectElement).value), + ) + }, + setVirtualMode(event: Event) { + controller.actions.setVirtualScrollEnabled( + (event.target as HTMLSelectElement).value === 'tanstack', + ) + }, + setRendererMode(event: Event) { + controller.actions.setRendererMode( + (event.target as HTMLInputElement).checked ? 'swap' : 'stable', + ) + }, + setSparklineUpdates(event: Event) { + feed.actions.setSparklineUpdates( + (event.target as HTMLInputElement).checked, + ) + }, + setSparklineInterval(event: Event) { + feed.actions.setSparklineSampleInterval( + Number((event.target as HTMLSelectElement).value), + ) + }, + metricItems() { + const metrics = local.benchmark.metrics + return [ + [ + 'FRAME RATE (EST.)', + metrics.rafCallbacksPerSecond.toFixed(1), + 'rAF callbacks/s · rolling 1 s', + 'frame-rate', + ], + [ + 'AVG COMMIT', + ms(metrics.averageCommitLatencyMs), + 'snapshot → DOM · rolling 3 s', + 'average-commit-latency', + ], + [ + 'LONG FRAMES', + local.benchmark.longAnimationFramesSupported + ? String(metrics.longAnimationFrames) + : 'N/A', + local.benchmark.longAnimationFramesSupported + ? `since reset · worst ${ms(metrics.worstLongAnimationFrameMs)}` + : 'unsupported', + 'long-frame-count', + ], + [ + 'THROUGHPUT', + `${rate.format(metrics.rowUpdatesPerSecond)} rows/s`, + `${metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows deduplicated per snapshot`, + 'throughput-rate', + ], + ] + }, + diagnostics() { + const metrics = local.benchmark.metrics + const invocation = (items: typeof metrics.cellRendererRates) => { + const active = items.filter((item) => item.callsPerSecond > 0) + return active.length + ? active + .map((item) => `${item.name} ${rate.format(item.callsPerSecond)}`) + .join(' · ') + : '—' + } + return [ + [ + 'Worker-generated samples / s', + rate.format(metrics.actualTicksPerSecond), + 'actual-rate', + ], + [ + 'Changed rows / s', + rate.format(metrics.rowUpdatesPerSecond), + 'row-update-rate', + ], + [ + 'Worker messages / s', + metrics.workerMessagesPerSecond.toFixed(1), + 'message-rate', + ], + [ + 'Snapshots applied / s', + metrics.stateApplicationsPerSecond.toFixed(1), + 'state-apply-rate', + ], + [ + 'DOM commits / s', + metrics.tableCommitsPerSecond.toFixed(1), + 'table-render-rate', + ], + [ + 'Commit latency p95 / max (10 s)', + `${ms(metrics.p95CommitLatencyMs)} / ${ms(metrics.maxCommitLatencyMs)}`, + '', + ], + ['Mounted cells', integer.format(local.benchmark.mountedCells), ''], + ['Live components', integer.format(local.benchmark.liveComponents), ''], + [ + 'Created / destroyed', + `${integer.format(metrics.componentsCreated)} / ${integer.format(metrics.componentsDestroyed)}`, + '', + ], + [ + 'Renderer callbacks / s', + rate.format(metrics.cellRendererCallsPerSecond), + 'cell-render-rate', + ], + [ + 'Component executions / s', + rate.format(metrics.componentRenderCallsPerSecond), + 'component-render-rate', + ], + [ + 'Executions by component / s', + invocation(metrics.componentRenderRates), + 'component-render-breakdown', + ], + [ + 'Callbacks by column / s', + invocation(metrics.cellRendererRates), + 'cell-render-breakdown', + ], + [ + 'Observed MutationRecords / s', + rate.format(metrics.domMutationsPerSecond), + 'dom-mutation-rate', + ], + [ + 'Core row model calls / s', + metrics.rowModelCallsPerSecond.toFixed(1), + 'row-model-call-rate', + ], + [ + 'Core row model avg / max', + `${ms(metrics.rowModelAverageMs)} / ${ms(metrics.rowModelMaxMs)}`, + 'row-model-duration', + ], + [ + 'Visible rows', + integer.format(metrics.visibleRows), + 'visible-row-count', + ], + [ + 'Worker messages', + integer.format(metrics.workerMessages), + 'worker-messages', + ], + [ + 'Worker-coalesced updates / s', + rate.format(metrics.supersededUpdatesPerSecond), + 'superseded-update-rate', + ], + [ + 'Last samples / updated rows', + `${integer.format(metrics.lastBatchSize)} / ${integer.format(metrics.lastUpdateCount)}`, + '', + ], + [ + 'Commits > 16.7 ms (since reset)', + integer.format(metrics.slowCommits), + '', + ], + [ + 'JS heap (current, GC-sensitive)', + metrics.heapMb === null ? 'N/A' : `${metrics.heapMb.toFixed(1)} MB`, + '', + ], + ] + }, + selectedQuote() { + return feed.getQuoteBySymbol(local.feed.quotes, local.selectedSymbol) + }, + formatInteger: (number: number) => integer.format(number), + formatRate: (number: number) => rate.format(number), + sortIndicator, + sortAriaValue, + isTextColumn(id: string) { + return ['market', 'name', 'symbol'].includes(id) + }, + onGridKeyDown(event: KeyboardEvent) { + handleCellNavigation(table, event) + }, + onBodyMouseDown(event: MouseEvent) { + pointer.handleMouseDown(table, event, controller.actions.selectSymbol) + }, + onBodyPointerOver(event: MouseEvent) { + pointer.handlePointerOver(table, event) + }, + onBodyClick(event: MouseEvent) { + pointer.handleClick(table, event) + }, + resetPointer() { + pointer.resetPointerCell() + }, + clearDrag() { + runtime.dragSource?.classList.remove('is-column-dragging') + runtime.dragTarget?.classList.remove('is-column-drop-target') + runtime.dragColumnId = null + runtime.dragSource = null + runtime.dragTarget = null + }, + dragStart( + header: ReturnType[number], + event: DragEvent, + ) { + runtime.dragColumnId = header.column.id + runtime.dragSource = (event.currentTarget as HTMLElement).closest('th') + runtime.dragSource?.classList.add('is-column-dragging') + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', header.column.id) + } + }, + dragOver( + header: ReturnType[number], + event: DragEvent, + ) { + event.preventDefault() + runtime.dragTarget?.classList.remove('is-column-drop-target') + runtime.dragTarget = null + const element = (event.currentTarget as HTMLElement).closest('th') + if (runtime.dragColumnId !== header.column.id && element) { + element.classList.add('is-column-drop-target') + runtime.dragTarget = element + } + }, + drop( + header: ReturnType[number], + event: DragEvent, + ) { + event.preventDefault() + const source = + event.dataTransfer?.getData('text/plain') || runtime.dragColumnId + if (source) + table.setColumnOrder( + reorderColumnIds( + table.getVisibleLeafColumns().map((column) => column.id), + source, + header.column.id, + ), + ) + this.clearDrag() + }, + } +}) + +window.Alpine = Alpine +Alpine.start() diff --git a/examples/alpine/realtime-trading/src/shell/configurator-options.ts b/examples/alpine/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/alpine/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/alpine/realtime-trading/src/table/table-config/quote-cells.ts b/examples/alpine/realtime-trading/src/table/table-config/quote-cells.ts new file mode 100644 index 0000000000..b5f115e28f --- /dev/null +++ b/examples/alpine/realtime-trading/src/table/table-config/quote-cells.ts @@ -0,0 +1,145 @@ +export const quoteCellLifecycle = { created: 0, destroyed: 0 } +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SparklineCell', +] as const +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] +const counters = (names: ReadonlyArray) => + Object.fromEntries(names.map((name) => [name, 0])) as Record +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: counters(quoteCellRendererNames), + componentRenderCallsByName: counters(quoteComponentNames), +} +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} +const signed = (value: number) => `${value >= 0 ? '+' : ''}${value.toFixed(2)}` + +abstract class QuoteElement extends HTMLElement { + protected abstract readonly componentName: QuoteComponentName + connectedCallback() { + quoteCellLifecycle.created++ + this.renderCell() + } + disconnectedCallback() { + quoteCellLifecycle.destroyed++ + } + attributeChangedCallback() { + if (this.isConnected) this.renderCell() + } + protected renderCell() { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[this.componentName]++ + } + protected number(name: string) { + return Number(this.getAttribute(name) ?? 0) + } +} + +class PriceCell extends QuoteElement { + protected readonly componentName = 'PriceCell' + static observedAttributes = ['price', 'move'] + protected renderCell() { + super.renderCell() + const price = this.number('price') + const move = this.number('move') + this.innerHTML = `` + } +} +abstract class MoveCell extends QuoteElement { + static observedAttributes = ['move'] + protected direction: 'up' | 'down' | null = null + protected indicator = '' + protected renderCell() { + super.renderCell() + const move = this.number('move') + const direction = this.direction ?? (move >= 0 ? 'up' : 'down') + this.innerHTML = `${this.indicator}${signed(move)}` + } +} +class StableMoveCell extends MoveCell { + protected readonly componentName = 'StableMoveCell' +} +class UpMoveCell extends MoveCell { + protected readonly componentName = 'UpMoveCell' + protected direction = 'up' as const + protected indicator = '▲ ' +} +class DownMoveCell extends MoveCell { + protected readonly componentName = 'DownMoveCell' + protected direction = 'down' as const + protected indicator = '▼ ' +} +class PercentChangeCell extends QuoteElement { + protected readonly componentName = 'PercentChangeCell' + static observedAttributes = ['value'] + protected renderCell() { + super.renderCell() + const value = this.number('value') + this.innerHTML = `${signed(value)}%` + } +} +class SparklineCell extends QuoteElement { + protected readonly componentName = 'SparklineCell' + static observedAttributes = ['points', 'rising'] + protected renderCell() { + super.renderCell() + this.innerHTML = `` + } +} + +const definitions = [ + ['quote-price-cell', PriceCell], + ['quote-stable-move', StableMoveCell], + ['quote-up-move', UpMoveCell], + ['quote-down-move', DownMoveCell], + ['quote-percent-change', PercentChangeCell], + ['quote-sparkline', SparklineCell], +] as const +for (const [name, definition] of definitions) + if (!customElements.get(name)) customElements.define(name, definition) + +export function sparklineMarkup(values: ReadonlyArray): string { + const first = values[0] ?? 0 + const range = values.reduce( + (current, value) => ({ + min: Math.min(current.min, value), + max: Math.max(current.max, value), + }), + { min: first, max: first }, + ) + const height = range.max - range.min || 1 + const denominator = Math.max(1, values.length - 1) + const points = values + .map( + (value, index) => + `${((index / denominator) * 100).toFixed(1)},${(22 - ((value - range.min) / height) * 20).toFixed(1)}`, + ) + .join(' ') + return `` +} diff --git a/examples/alpine/realtime-trading/src/table/table-config/trading-columns.ts b/examples/alpine/realtime-trading/src/table/table-config/trading-columns.ts new file mode 100644 index 0000000000..24c85591a1 --- /dev/null +++ b/examples/alpine/realtime-trading/src/table/table-config/trading-columns.ts @@ -0,0 +1,245 @@ +import { recordCellRender, sparklineMarkup } from './quote-cells' +import type { ColumnDef, TableFeatures } from '@tanstack/alpine-table' +import type { MarketQuote } from '../../feed/market-data' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} +interface CellContext { + row: { original: MarketQuote } +} +interface Definition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: CellContext) => unknown +} +const compact = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const safe = (value: string) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +export function createTradingColumns( + getRendererMode: () => RendererMode, +): Array> { + const columns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => + recordCellRender('Market', safe(row.original.venue)), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => + recordCellRender('Name', safe(row.original.company)), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => + recordCellRender('Symbol', safe(row.original.symbol)), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender( + 'Last', + ``, + ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: getDayChange, + cell: ({ row }) => + recordCellRender( + 'Change', + moveMarkup(getRendererMode(), getDayChange(row.original)), + ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: getDayChangePercent, + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + ``, + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => + recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender('BidVolume', compact.format(row.original.bidSize)), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => + recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender('AskVolume', compact.format(row.original.askSize)), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => + recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender('Intraday', sparklineMarkup(row.original.history)), + }, + ], + }, + ] + return columns as unknown as Array> +} +function moveMarkup(mode: RendererMode, move: number) { + if (mode === 'stable') + return `` + return move >= 0 + ? `` + : `` +} +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} +export const TRADING_COLUMN_COUNT = 14 +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 1_000 === 0) + performance.clearMeasures('tanstack-row-model') + } catch { + /* optional sampled User Timing detail */ + } + } + return rows +} +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/alpine/realtime-trading/src/table/table-interactions.ts b/examples/alpine/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..99649a40bd --- /dev/null +++ b/examples/alpine/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,187 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/alpine/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/alpine/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/alpine/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/alpine/realtime-trading/src/table/trading-table.ts b/examples/alpine/realtime-trading/src/table/trading-table.ts new file mode 100644 index 0000000000..77e4e6bee1 --- /dev/null +++ b/examples/alpine/realtime-trading/src/table/trading-table.ts @@ -0,0 +1,9 @@ +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-columns' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns' +export type { VirtualScrollMode } from './trading-row-virtualizer' diff --git a/examples/alpine/realtime-trading/src/vite-env.d.ts b/examples/alpine/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..99f6333028 --- /dev/null +++ b/examples/alpine/realtime-trading/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +import type Alpine from 'alpinejs' + +declare global { + interface Window { + Alpine: typeof Alpine + } +} diff --git a/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts b/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..9ed2b833bc --- /dev/null +++ b/examples/alpine/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Alpine realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/alpine/realtime-trading/tsconfig.json b/examples/alpine/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..4986527dc3 --- /dev/null +++ b/examples/alpine/realtime-trading/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/alpine/realtime-trading/vite.config.ts b/examples/alpine/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..f8924ba6ce --- /dev/null +++ b/examples/alpine/realtime-trading/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite' +export default defineConfig({ server: { port: 7784, allowedHosts: true } }) diff --git a/examples/angular/realtime-trading/README.md b/examples/angular/realtime-trading/README.md new file mode 100644 index 0000000000..35bae0169a --- /dev/null +++ b/examples/angular/realtime-trading/README.md @@ -0,0 +1,243 @@ +# Angular realtime trading benchmark + +This is a standalone stress example for the current TanStack Angular Table +adapter. It combines a high-frequency synthetic market feed, immutable row +snapshots, sortable and resizable columns, range selection, dynamic Angular +cell components, optional row virtualization, and browser performance +instrumentation. + +The goal is not to reproduce an exchange. It is to provide a repeatable grid +workload in which feed generation, message delivery, state application, table +work, Angular rendering, and browser layout can be measured separately. + +## Run and verify + +```bash +pnpm --dir examples/angular/realtime-trading dev +``` + +Open `http://localhost:7777`. + +Use a production build before recording representative timings: + +```bash +pnpm --dir examples/angular/realtime-trading build +pnpm --dir examples/angular/realtime-trading test:types +pnpm --dir examples/angular/realtime-trading lint +pnpm --dir examples/angular/realtime-trading test:e2e +``` + +Development mode includes Angular assertions and extra benchmark bookkeeping, +so its absolute timings should not be compared with a production build. + +## Directory structure + +| Path | Responsibility | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `src/app/feed/` | Market types, the instrument universe, feed configuration, immutable update helpers, and the Angular feed service. | +| `src/app/feed/worker/` | Worker protocol, deterministic market engine, and the module Web Worker that generates and publishes quote updates. | +| `src/app/benchmark/` | Performance monitor and the controller that connects feed lifecycle events to UI diagnostics. | +| `src/app/shell/` | Full-viewport layout, header, metrics strip, sidebar configurator, selected-instrument panel, diagnostics, and status bar. | +| `src/app/table/table-config/` | Grouped column definitions and custom quote cell components. | +| `src/app/table/view/` | Header component plus table-level selection and cell host directives. | +| `src/app/table/` | Current table, table interactions, initial column fitting, and the TanStack Virtual integration. | +| `src/app/table/worker/` | Optional experimental table-worker implementation and its row-model worker. | +| `src/app/app.ts` | Composition root; it projects only the selected table implementation into the shell. | + +The feed, benchmark, shell, and table layers are deliberately separate. The +feed can run without the benchmark monitor, and shell components inject only +the controller or service whose state they display. + +## Data and worker pipeline + +The initial configuration is 100 instruments, 10K generated samples per +second, a 20 ms delivery interval, enabled intraday charts, and 16 ms intraday +sampling. + +1. `MarketFeedService` creates a module worker and sends the current + configuration. +2. `market-feed-engine.ts` owns mutable quote state inside the worker. A + deterministic PRNG and stable instrument IDs keep runs reproducible. +3. A 16 ms generation loop accrues a fractional sample budget and applies the + requested synthetic workload to the worker-owned quotes. +4. Updated instruments are stored in a `Map` by row index. Repeated updates to + one instrument are coalesced, retaining its latest value. +5. A separate publication timer posts one message at the configured delivery + interval. Generation rate and message rate are therefore independent. +6. The main thread creates a new outer quotes array and replaces only the row + objects included in the message. Unchanged rows retain their references. +7. A session ID rejects messages from an obsolete worker configuration after a + reset or instrument-count change. + +The terms in the UI are intentionally distinct: + +- **Synthetic quote workload** is generated samples per second inside the + worker. It is not DOM events, table renders, or `postMessage` calls. +- **Worker delivery interval** is the target cadence for coalesced messages. + For example, 20 ms targets roughly 50 messages per second. +- **Row updates** is the number of unique immutable row objects applied on the + main thread. It can be lower than generated samples because of coalescing. +- **Message samples** is the number of generated samples represented by the + latest delivered batch. + +Intraday history is sampled independently of quote generation. Its interval +controls how frequently a row receives a new history array; other quote fields +can continue changing between history samples. The 25K burst command generates +and flushes one intentionally expensive batch immediately. + +The worker is analogous to an upstream WebSocket/SSE producer, but it is not a +network benchmark: serialization, browser worker messaging, main-thread state +application, and rendering are in scope; network latency is not. + +## Angular state and rendering architecture + +- `MarketFeedService` owns feed signals and worker lifecycle. +- `TradingBenchmarkController` owns benchmark-only state, selected symbol, + renderer mode, virtualization preference, and derived metrics. +- The application is zoneless and uses signal inputs, outputs, queries, + `computed`, `effect`, `DestroyRef`, and `OnPush` components. +- The root component does not subscribe to individual diagnostics. It only + chooses the normal or experimental worker-backed table and supplies the same + signal values to it. +- Custom cells are declared with `flexRenderComponent` only where an Angular + component is required. Plain columns remain plain render callbacks. +- Stable row IDs come from the instrument ID, so sorting and updates do not + make row identity depend on the current array position. + +The optional **table worker** is separate from the market-feed worker. The feed +worker always produces market data; the table worker moves the supported +row-model work off the main thread. Both table paths intentionally have their +own column configuration boundary so component render tokens stay explicit. + +## Table behavior + +The grid has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart sections. It supports: + +- core sorting and filtering; +- on-change column resizing, including double-click reset; +- drag-and-drop leaf-column ordering with a visible drop target; +- CSS-only row hover; +- click/modified-click row selection; +- mouse-drag cell range selection and keyboard navigation; +- custom Price, Move, Percent Change, and Sparkline components; +- a stress mode that swaps the move component type when direction changes, + intentionally exercising destruction and recreation. + +Selection events are delegated once at the table boundary by +`TradingGridSelectionDirective`. It resolves a cell from `event.composedPath()` +and data attributes, avoiding a `mousedown`/move listener per cell. The cell +directive uses signal inputs and host properties for width, focus, ARIA, and +selection-edge attributes; it does not toggle CSS classes imperatively. + +Column sizes are written to table-level CSS custom properties. Cells reference +those variables rather than recomputing an inline pixel width during every +quote update. A `ResizeObserver` expands the initial column sizes to available +width; manual resizing disables subsequent automatic fitting. + +## Full DOM and virtual rows + +The internal preference is `auto`, `tanstack`, or `none`: + +- Below 200 rows, `auto` resolves to **Full DOM**, but virtualization remains + manually selectable. +- From 200 through 1,499 rows, `auto` resolves to **TanStack Virtual**, and the + user may still switch back to Full DOM. +- At 1,500 rows or more, virtualization is forced and the control is locked to + prevent an accidental full-table mount. + +TanStack Virtual uses a fixed 32 px estimate, 10-row overscan, instrument row +IDs as item keys, cached measurements, and transformed rows inside a body-sized +spacer. Only the visible overscan window creates Angular row views. The footer +reads the virtualizer range and reports the current row interval. + +The Angular virtualizer disables application-wide ticks and schedules a local +`detectChanges()` flush for virtualizer changes. This confines scroll-driven +updates to the table instead of checking the complete application shell. + +Both Full DOM and virtual rows use `content-visibility: auto` with a matching +intrinsic block size. In Full DOM this may save browser style/layout/paint work, +but Angular still creates every row and cell. In virtual mode it is an +additional browser hint; virtualization remains the mechanism that limits +mounted views. + +## Performance decisions + +- Market calculations execute off the main thread. +- Repeated worker samples are coalesced before crossing the worker boundary. +- Immutable snapshots preserve unchanged row and history references. +- Stable row IDs and keyed `@for` blocks preserve row identity. +- High-frequency selection input is delegated at the table, not bound per + cell. +- Column widths use CSS variables and update only on sizing/order changes. +- Shell widgets read narrow signal state and do not make the root consume the + complete feed. +- Dynamic component churn is opt-in; the stable renderer is the realistic + baseline. +- Virtualization limits framework and DOM work for larger data sets. +- Benchmark metrics publish at a lower cadence than the data feed. + +`content-visibility`, worker generation, and virtualization solve different +problems. None of them prevents the table core from rebuilding a sorted or +filtered row model when its data/state inputs require it. + +## Diagnostics + +The benchmark layer observes the feed through callbacks, so it can be removed +without changing feed semantics. It reports: + +- a compact **Live health** summary in the configurator: estimated frame + callbacks over a rolling 1-second window, average market-mutation-to-DOM- + commit latency over 3 seconds, cumulative long animation frames, and + throughput as changed rows plus applied snapshots per second; +- generated worker samples, changed rows, received messages, state applies, + and completed DOM commits; +- p95/maximum commit latency over a rolling 10-second window and commits over + 16.7 ms accumulated since reset; +- long animation frames and worst duration when the browser supports the Long + Animation Frames API; +- mounted cell hosts and live/created/destroyed dynamic components; +- cell-render callbacks by column and component executions by type; +- DOM `MutationRecord` delivery rate from a `MutationObserver` limited to text, + child-list, and `class`/`style` attributes; +- core row-model call rate and average/maximum duration; +- heap information where the browser exposes it. + +The frame-rate value is an estimated `requestAnimationFrame` callback rate, not +GPU-presented FPS; its healthy ceiling follows the display refresh rate. The +latency starts at the first pending feed mutation and ends in Angular's +`afterEveryRender` callback, after Angular has committed the DOM. + +`Changed rows/s` sums the update array lengths delivered by each snapshot. +Symbols are deduplicated inside one worker message, but the same row can count +again in the next snapshot; it is therefore applied row throughput, not the +number of distinct instruments touched during the entire second. + +Renderer callback counts are not DOM mutation counts. A callback can execute +and still reuse a component/view, while a component execution count records the +framework component path itself. `MutationObserver` reports delivered records, +not a one-to-one count of browser DOM operations; record coalescing and custom +component internals affect it, and observing a hot subtree has profiling +overhead. The attribute filter excludes selection-oriented `data-*` changes to +reduce noise. Heap is Chromium-only and GC-sensitive. Growth during swap mode is +not automatically a leak: confirm retention with repeated post-GC snapshots. +The rAF loop only appends a timestamp; rolling aggregation and heap reads run at +the 500 ms metrics publication cadence. Mutation observation remains the most +intrusive diagnostic because the browser must create records for the observed +subtree. + +Use Chrome Performance/Angular DevTools for call stacks and frame analysis, and +the in-app diagnostics for controlled comparisons. Keep row count, workload, +delivery interval, renderer mode, and virtual mode identical between runs. + +## Standalone example policy + +This folder intentionally contains its own instruments, feed engine, worker, +benchmark monitor, styles, and UI instead of importing a shared demo package. +That duplication lets the example run independently and be copied to +StackBlitz. Shared concepts and some README text are therefore repeated across +adapter examples by design. + +The package pins `@tanstack/angular-table` to the repository version. The root +workspace override resolves it to `packages/angular-table` while retaining a +release-like example manifest. diff --git a/examples/angular/realtime-trading/angular.json b/examples/angular/realtime-trading/angular.json new file mode 100644 index 0000000000..5512416689 --- /dev/null +++ b/examples/angular/realtime-trading/angular.json @@ -0,0 +1,76 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "pnpm", + "analytics": false, + "cache": { + "enabled": false + } + }, + "newProjectRoot": "projects", + "projects": { + "realtime-trading": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "inlineTemplate": true, + "inlineStyle": true, + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "tsConfig": "tsconfig.app.json", + "assets": [], + "styles": ["src/styles.css"] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "4kB", + "maximumError": "8kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "allowedHosts": true + }, + "configurations": { + "production": { + "buildTarget": "realtime-trading:build:production" + }, + "development": { + "buildTarget": "realtime-trading:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + } +} diff --git a/examples/angular/realtime-trading/package.json b/examples/angular/realtime-trading/package.json new file mode 100644 index 0000000000..1f270dbc84 --- /dev/null +++ b/examples/angular/realtime-trading/package.json @@ -0,0 +1,31 @@ +{ + "name": "tanstack-angular-table-example-realtime-trading", + "scripts": { + "ng": "ng", + "start": "ng serve --port 7777", + "dev": "ng serve --port 7777", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "lint": "eslint ./src", + "test:types": "tsc -p tsconfig.app.json --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "private": true, + "packageManager": "pnpm@11.18.0", + "dependencies": { + "@angular/common": "^22.1.0", + "@angular/compiler": "^22.1.0", + "@angular/core": "^22.1.0", + "@angular/platform-browser": "^22.1.0", + "@tanstack/angular-table": "9.1.3", + "@tanstack/angular-virtual": "^6.0.2", + "rxjs": "~7.8.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@angular/build": "^22.1.2", + "@angular/cli": "^22.1.2", + "@angular/compiler-cli": "^22.1.0", + "typescript": "6.0.3" + } +} diff --git a/examples/angular/realtime-trading/src/app/app.config.ts b/examples/angular/realtime-trading/src/app/app.config.ts new file mode 100644 index 0000000000..cbb47d366c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/app.config.ts @@ -0,0 +1,6 @@ +import { provideBrowserGlobalErrorListeners } from '@angular/core' +import type { ApplicationConfig } from '@angular/core' + +export const appConfig: ApplicationConfig = { + providers: [provideBrowserGlobalErrorListeners()], +} diff --git a/examples/angular/realtime-trading/src/app/app.ts b/examples/angular/realtime-trading/src/app/app.ts new file mode 100644 index 0000000000..e348e9374c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/app.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from './benchmark/trading-benchmark.controller' +import { CurrentTradingTable } from './table/current-trading-table' +import { TradingShell } from './shell/trading-shell' +import { WorkerTradingTable } from './table/worker/worker-trading-table' + +@Component({ + selector: 'app-root', + imports: [CurrentTradingTable, TradingShell, WorkerTradingTable], + template: ` + + @if (controller.tableWorkerEnabled()) { + + } @else { + + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + readonly controller = inject(TradingBenchmarkController) +} diff --git a/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..03a22f74b0 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/benchmark/benchmark-monitor.ts @@ -0,0 +1,254 @@ +import { quoteCellLifecycle } from '../table/table-config/quote-cells' + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + domMutationsPerSecond: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + domMutationsPerSecond: 0, +} + +interface CommitLatencySample { + recordedAt: number + duration: number +} + +const AVERAGE_COMMIT_WINDOW_MS = 3_000 +const PERCENTILE_COMMIT_WINDOW_MS = 10_000 +const FRAME_RATE_WINDOW_MS = 1_000 + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + frameTrackingStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + renderSamples: [] as Array, + frameTimestamps: [] as Array, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + tableRendersInSample: 0, + slowRenderCount: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + domMutationsInSample: 0, + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + recordCompletedRender(): void { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + const renderEndedAt = performance.now() + const duration = renderEndedAt - runtime.pendingRenderStartedAt + runtime.renderSamples.push({ recordedAt: renderEndedAt, duration }) + if (duration > 16.7) runtime.slowRenderCount++ + runtime.pendingRenderStartedAt = null + runtime.tableRendersInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + this.#runtime.frameTimestamps.push(now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + runtime.renderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - PERCENTILE_COMMIT_WINDOW_MS, + ) + runtime.frameTimestamps = runtime.frameTimestamps.filter( + (timestamp) => timestamp >= now - FRAME_RATE_WINDOW_MS, + ) + const averageRenderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - AVERAGE_COMMIT_WINDOW_MS, + ) + const sortedRenderSamples = runtime.renderSamples + .map((sample) => sample.duration) + .sort((left, right) => left - right) + const averageRenderMs = + averageRenderSamples.length === 0 + ? 0 + : averageRenderSamples.reduce( + (sum, sample) => sum + sample.duration, + 0, + ) / averageRenderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: + (runtime.frameTimestamps.length / + Math.min( + FRAME_RATE_WINDOW_MS, + Math.max(1, now - runtime.frameTrackingStartedAt), + )) * + 1_000, + tableRendersPerSecond: + (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: runtime.slowRenderCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + } + + runtime.sampleStartedAt = now + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.domMutationsInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingRenderStartedAt = null + runtime.renderSamples = [] + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.tableRendersInSample = 0 + runtime.slowRenderCount = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.domMutationsInSample = 0 + } +} + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} diff --git a/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts b/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts new file mode 100644 index 0000000000..5f8e15bbef --- /dev/null +++ b/examples/angular/realtime-trading/src/app/benchmark/trading-benchmark.controller.ts @@ -0,0 +1,144 @@ +import { + DestroyRef, + Injectable, + computed, + inject, + isDevMode, + signal, +} from '@angular/core' +import { TRADING_COLUMN_COUNT } from '../table/table-config/trading-column-types' +import { MarketFeedService } from '../feed/market-feed.service' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { BenchmarkMonitor, initialMetrics } from './benchmark-monitor' +import type { + VirtualScrollMode, + VirtualScrollPreference, +} from '../table/trading-row-virtualizer' +import type { FeedMetrics } from './benchmark-monitor' +import type { RendererMode } from '../table/table-config/trading-column-types' + +@Injectable({ providedIn: 'root' }) +export class TradingBenchmarkController { + readonly #destroyRef = inject(DestroyRef) + readonly #monitor = new BenchmarkMonitor() + readonly #feed = inject(MarketFeedService) + readonly #longAnimationFrameObserver: PerformanceObserver | null + + readonly devMode = isDevMode() + readonly longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + readonly tableWorkerEnabled = signal(false) + readonly renderedRowCount = signal(0) + readonly rendererMode = signal('stable') + readonly requestedVirtualScrollMode = signal('auto') + readonly selectedSymbol = signal(null) + readonly metrics = signal(initialMetrics) + readonly displayQuotes = this.#feed.quotes + readonly selectedQuote = computed(() => { + const symbol = this.selectedSymbol() + return symbol + ? (this.displayQuotes().find((quote) => quote.symbol === symbol) ?? null) + : null + }) + readonly virtualScrollForced = computed( + () => this.#feed.instrumentCount() >= FORCED_VIRTUALIZATION_ROW_COUNT, + ) + readonly virtualScrollMode = computed(() => + resolveVirtualScrollMode( + this.requestedVirtualScrollMode(), + this.#feed.instrumentCount(), + ), + ) + readonly mountedCells = computed( + () => this.renderedRowCount() * TRADING_COLUMN_COUNT, + ) + readonly liveComponents = computed(() => { + const metrics = this.metrics() + return metrics.componentsCreated - metrics.componentsDestroyed + }) + + #animationFrameId: number | null = null + + constructor() { + const stopObservingFeed = this.#feed.observe({ + messageReceived: () => this.#monitor.recordWorkerMessage(), + mutationStarted: () => this.#monitor.markRenderPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.#monitor.recordBatch( + tickCount, + updateCount, + supersededUpdateCount, + ), + renderCommitted: () => this.#monitor.recordCompletedRender(), + }) + this.#longAnimationFrameObserver = this.longAnimationFramesSupported + ? new PerformanceObserver(this.#recordLongAnimationFrames) + : null + this.#longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + this.#destroyRef.onDestroy(() => { + stopObservingFeed() + if (this.#animationFrameId !== null) { + cancelAnimationFrame(this.#animationFrameId) + } + this.#longAnimationFrameObserver?.disconnect() + }) + } + + setRendererMode(shouldSwap: boolean): void { + this.rendererMode.set(shouldSwap ? 'swap' : 'stable') + } + + setTableWorkerEnabled(enabled: boolean): void { + this.tableWorkerEnabled.set(enabled) + } + + setVirtualScrollEnabled(enabled: boolean): void { + if (this.virtualScrollForced()) return + this.requestedVirtualScrollMode.set(enabled ? 'tanstack' : 'none') + } + + setRenderedRowCount(count: number): void { + this.renderedRowCount.set(count) + } + + recordDomMutations(count: number): void { + this.#monitor.recordDomMutations(count) + } + + resetDomMutations(): void { + this.#monitor.resetDomMutations() + } + + resetViewState(): void { + this.selectedSymbol.set(null) + } + + resetMarket(): void { + this.#monitor.reset() + this.resetViewState() + this.metrics.set(initialMetrics) + this.#feed.reset() + } + + readonly #benchmarkFrame = (now: number): void => { + this.#monitor.recordAnimationFrame(now) + if (this.#monitor.shouldPublish(now)) { + this.metrics.set(this.#monitor.publish(now)) + } + this.#animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + readonly #recordLongAnimationFrames = ( + entries: PerformanceObserverEntryList, + ): void => { + for (const entry of entries.getEntries()) { + this.#monitor.recordLongAnimationFrame(entry.duration, entry.startTime) + } + } +} diff --git a/examples/angular/realtime-trading/src/app/feed/feed-sample-rates.ts b/examples/angular/realtime-trading/src/app/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/angular/realtime-trading/src/app/feed/market-data.ts b/examples/angular/realtime-trading/src/app/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/angular/realtime-trading/src/app/feed/market-feed-config.ts b/examples/angular/realtime-trading/src/app/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts b/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts new file mode 100644 index 0000000000..284ba12ac9 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/market-feed.service.ts @@ -0,0 +1,175 @@ +import { + DestroyRef, + Injectable, + afterEveryRender, + inject, + signal, +} from '@angular/core' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +@Injectable({ providedIn: 'root' }) +export class MarketFeedService { + readonly #destroyRef = inject(DestroyRef) + readonly #worker = new Worker( + new URL('./worker/market-feed.worker', import.meta.url), + { type: 'module' }, + ) + readonly #observers = new Set() + + readonly workerReady = signal(false) + readonly running = signal(true) + readonly instrumentCount = signal(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = signal( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = signal(initialMarketFeedConfig.publishIntervalMs) + readonly updateSparklines = signal(initialMarketFeedConfig.updateSparklines) + readonly sparklineSampleIntervalMs = signal( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = signal>([]) + + #feedSessionId = 0 + #renderPending = false + + constructor() { + afterEveryRender(() => this.completeRender()) + this.#worker.addEventListener('message', this.#handleWorkerMessage) + this.#worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount(), + running: this.running(), + ticksPerSecond: this.targetTicksPerSecond(), + publishIntervalMs: this.publishIntervalMs(), + updateSparklines: this.updateSparklines(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs(), + }) + this.#destroyRef.onDestroy(() => { + this.#worker.removeEventListener('message', this.#handleWorkerMessage) + this.#worker.removeEventListener('error', this.#handleWorkerError) + this.#worker.terminate() + this.#observers.clear() + }) + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + completeRender(): void { + if (!this.#renderPending) return + + this.#renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + } + + toggle(): void { + const running = !this.running() + this.running.set(running) + this.#post({ type: 'set-running', running }) + } + + setInstrumentCount(count: number): void { + this.instrumentCount.set(count) + this.reset() + } + + setTargetRate(rate: number): void { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + } + + setPublishInterval(publishIntervalMs: number): void { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ type: 'set-publish-interval', intervalMs: publishIntervalMs }) + } + + setSparklineUpdates(enabled: boolean): void { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + } + + setSparklineSampleInterval(intervalMs: number): void { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + } + + runBurst(): void { + this.#post({ type: 'burst', tickCount: 25_000 }) + } + + reset(): void { + this.workerReady.set(false) + this.#post({ type: 'reset', rowCount: this.instrumentCount() }) + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#feedSessionId = data.sessionId + this.#startMutation() + this.quotes.set(hydrateMarketQuotes(data.quotes)) + this.workerReady.set(true) + return + } + + if (data.sessionId !== this.#feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.update((quotes) => applyMarketUpdates(quotes, data.updates)) + const batch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(batch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.workerReady.set(false) + this.running.set(false) + console.error('Market feed worker failed', error) + } + + #startMutation(): void { + this.#renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#worker.postMessage(command) + } +} diff --git a/examples/angular/realtime-trading/src/app/feed/market-instruments.ts b/examples/angular/realtime-trading/src/app/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..86a68e2f64 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts b/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/angular/realtime-trading/src/app/shell/configurator-options.ts b/examples/angular/realtime-trading/src/app/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/angular/realtime-trading/src/app/shell/configurator.html b/examples/angular/realtime-trading/src/app/shell/configurator.html new file mode 100644 index 0000000000..3e3928d479 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/configurator.html @@ -0,0 +1,177 @@ + diff --git a/examples/angular/realtime-trading/src/app/shell/configurator.ts b/examples/angular/realtime-trading/src/app/shell/configurator.ts new file mode 100644 index 0000000000..83f8ab41b9 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/configurator.ts @@ -0,0 +1,52 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { MarketFeedService } from '../feed/market-feed.service' +import { configuratorOptions } from './configurator-options' +import { Diagnostics } from './diagnostics' +import { MetricsStrip } from './metrics-strip' +import { SelectedInstrument } from './selected-instrument' +import { + formatRate, + inputChecked, + inputValue, + selectValue, +} from './shell-formatters' + +@Component({ + selector: 'app-configurator', + imports: [Diagnostics, MetricsStrip, SelectedInstrument], + templateUrl: './configurator.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Configurator { + readonly controller = inject(TradingBenchmarkController) + readonly feed = inject(MarketFeedService) + readonly formatRate = formatRate + readonly options = configuratorOptions + readonly sampleRateOptions = feedSampleRateOptions + readonly sampleRateIndex = feedSampleRateIndex + + readonly setRowCount = (event: Event) => { + this.controller.resetViewState() + this.feed.setInstrumentCount(Number(selectValue(event))) + } + readonly setTargetRate = (event: Event) => + this.feed.setTargetRate(feedSampleRateAt(Number(inputValue(event)))) + readonly setPublishInterval = (event: Event) => + this.feed.setPublishInterval(Number(selectValue(event))) + readonly setRendererMode = (event: Event) => + this.controller.setRendererMode(inputChecked(event)) + readonly setTableWorkerEnabled = (event: Event) => + this.controller.setTableWorkerEnabled(inputChecked(event)) + readonly setVirtualScrollMode = (event: Event) => + this.controller.setVirtualScrollEnabled(selectValue(event) === 'tanstack') + readonly setSparklineUpdates = (event: Event) => + this.feed.setSparklineUpdates(inputChecked(event)) + readonly setSparklineSampleInterval = (event: Event) => + this.feed.setSparklineSampleInterval(Number(selectValue(event))) +} diff --git a/examples/angular/realtime-trading/src/app/shell/diagnostics.ts b/examples/angular/realtime-trading/src/app/shell/diagnostics.ts new file mode 100644 index 0000000000..0817db8c2b --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/diagnostics.ts @@ -0,0 +1,134 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { formatInteger, formatRate } from './shell-formatters' + +@Component({ + selector: 'app-diagnostics', + template: ` +
+

DIAGNOSTICS

+
+
+
Rendered rows / source
+
+ {{ formatInteger(controller.renderedRowCount()) }} / + {{ formatInteger(controller.displayQuotes().length) }} +
+
+
+
Mounted cell hosts
+
{{ formatInteger(controller.mountedCells()) }}
+
+
+
Live components
+
{{ formatInteger(controller.liveComponents()) }}
+
+
+
Created / destroyed
+
+ {{ formatInteger(controller.metrics().componentsCreated) }} / + {{ formatInteger(controller.metrics().componentsDestroyed) }} +
+
+
+
Worker samples / s
+
+ {{ formatRate(controller.metrics().actualTicksPerSecond) }} +
+
+
+
Worker messages / s
+
+ {{ controller.metrics().workerMessagesPerSecond.toFixed(1) }} +
+
+
+
Changed rows / s
+
+ {{ formatRate(controller.metrics().rowUpdatesPerSecond) }} +
+
+
+
State snapshots / s
+
+ {{ controller.metrics().stateApplicationsPerSecond.toFixed(1) }} +
+
+
+
Table DOM commits / s
+
+ {{ controller.metrics().tableRendersPerSecond.toFixed(1) }} +
+
+
+
P95 / max commit latency (rolling 10 s)
+
+ {{ controller.metrics().p95RenderMs.toFixed(2) }} ms / + {{ controller.metrics().maxRenderMs.toFixed(2) }} ms +
+
+
+
Worker messages since reset
+
+ {{ formatInteger(controller.metrics().workerMessages) }} +
+
+
+
Worker-coalesced updates / s
+
+ {{ formatRate(controller.metrics().supersededUpdatesPerSecond) }} +
+
+
+
Last message samples / updated rows
+
+ {{ formatInteger(controller.metrics().lastBatchSize) }} / + {{ formatInteger(controller.metrics().lastUpdateCount) }} +
+
+
+
Commits > 16.7 ms since reset
+
{{ controller.metrics().slowRenders }}
+
+
+
Observed MutationRecords / s
+
+ {{ formatRate(controller.metrics().domMutationsPerSecond) }} +
+
+
+
Long animation frames
+
+ {{ + controller.longAnimationFramesSupported + ? formatInteger(controller.metrics().longAnimationFrames) + : 'Unsupported' + }} +
+
+
+
JS heap (GC-sensitive)
+
+ {{ + controller.metrics().heapMb === null + ? 'N/A' + : controller.metrics().heapMb!.toFixed(1) + ' MB' + }} +
+
+
+

+ MutationObserver counts delivered records, not individual DOM + operations, and adds profiling overhead. Only class/style attributes, + text, and child-list changes are observed. Heap is a Chromium-only + point-in-time value and can move before garbage collection. +

+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Diagnostics { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger + readonly formatRate = formatRate +} diff --git a/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts b/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts new file mode 100644 index 0000000000..e092b39875 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/market-statusbar.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { formatInteger } from './shell-formatters' + +@Component({ + selector: 'app-market-statusbar', + template: ` +
+ + MESSAGE SAMPLES + + {{ formatInteger(controller.metrics().lastBatchSize) }} + + + + CHANGED ROWS + + {{ formatInteger(controller.metrics().lastUpdateCount) }} + + + + CELL HOSTS + {{ formatInteger(controller.mountedCells()) }} + + + COMPONENTS + {{ formatInteger(controller.liveComponents()) }} + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MarketStatusbar { + readonly controller = inject(TradingBenchmarkController) + readonly formatInteger = formatInteger +} diff --git a/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts b/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts new file mode 100644 index 0000000000..7e92dddac0 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/metrics-strip.ts @@ -0,0 +1,60 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { formatMs, formatRate } from './shell-formatters' + +@Component({ + selector: 'app-metrics-strip', + template: ` +
+

LIVE HEALTH

+
+ FRAME RATE (EST.) + + {{ controller.metrics().rafCallbacksPerSecond.toFixed(1) }} + + rAF callbacks/s · rolling 1 s +
+
+ AVG COMMIT + + {{ formatMs(controller.metrics().averageRenderMs) }} + + snapshot → DOM · rolling 3 s +
+
+ LONG FRAMES + @if (controller.longAnimationFramesSupported) { + + {{ controller.metrics().longAnimationFrames }} + + + since reset · worst + {{ formatMs(controller.metrics().worstLongAnimationFrameMs) }} + + } @else { + N/A + unsupported + } +
+
+ THROUGHPUT + + {{ formatRate(controller.metrics().rowUpdatesPerSecond) }} rows/s + + + {{ controller.metrics().stateApplicationsPerSecond.toFixed(1) }} + snapshots/s · rows deduplicated per snapshot + +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MetricsStrip { + readonly controller = inject(TradingBenchmarkController) + readonly formatMs = formatMs + readonly formatRate = formatRate +} diff --git a/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts b/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts new file mode 100644 index 0000000000..bd537d13ba --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/selected-instrument.ts @@ -0,0 +1,41 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' + +@Component({ + selector: 'app-selected-instrument', + template: ` +
+

SELECTED INSTRUMENT

+ @if (controller.selectedQuote(); as quote) { +
+
+ {{ quote.symbol }} + {{ quote.company }} +
+ {{ quote.venue }} +
+
+
+
Last
+
{{ quote.price.toFixed(2) }}
+
+
+
Bid / ask
+
{{ quote.bid.toFixed(2) }} / {{ quote.ask.toFixed(2) }}
+
+
+ } @else { +

+ Click or begin a cell selection in any row to inspect its instrument. +

+ } +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectedInstrument { + readonly controller = inject(TradingBenchmarkController) +} diff --git a/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts b/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts new file mode 100644 index 0000000000..5526e66d53 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/shell-formatters.ts @@ -0,0 +1,19 @@ +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const formatInteger = (value: number): string => + integerFormatter.format(value) +export const formatRate = (value: number): string => rateFormatter.format(value) +export const formatMs = (value: number): string => `${value.toFixed(2)} ms` + +export const selectValue = (event: Event): string => + (event.target as HTMLSelectElement).value +export const inputValue = (event: Event): string => + (event.target as HTMLInputElement).value +export const inputChecked = (event: Event): boolean => + (event.target as HTMLInputElement).checked diff --git a/examples/angular/realtime-trading/src/app/shell/shell-header.ts b/examples/angular/realtime-trading/src/app/shell/shell-header.ts new file mode 100644 index 0000000000..0398004644 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/shell-header.ts @@ -0,0 +1,59 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + output, +} from '@angular/core' +import { MarketFeedService } from '../feed/market-feed.service' + +@Component({ + selector: 'app-shell-header', + template: ` +
+
+ MARKET MONITOR +
+
+ + + {{ + !feed.workerReady() + ? 'FEED CONNECTING' + : feed.running() + ? 'FEED LIVE' + : 'FEED PAUSED' + }} + + +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ShellHeader { + readonly feed = inject(MarketFeedService) + readonly sidebarOpen = input.required() + readonly sidebarToggle = output() +} diff --git a/examples/angular/realtime-trading/src/app/shell/trading-shell.html b/examples/angular/realtime-trading/src/app/shell/trading-shell.html new file mode 100644 index 0000000000..ddce4e567d --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/trading-shell.html @@ -0,0 +1,20 @@ +
+
+ + + @if (devMode) { + + } +
+ +
+ +
+ + + +
diff --git a/examples/angular/realtime-trading/src/app/shell/trading-shell.ts b/examples/angular/realtime-trading/src/app/shell/trading-shell.ts new file mode 100644 index 0000000000..02ffb4850f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/shell/trading-shell.ts @@ -0,0 +1,22 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { Configurator } from './configurator' +import { MarketStatusbar } from './market-statusbar' +import { ShellHeader } from './shell-header' + +@Component({ + selector: 'app-trading-shell', + imports: [Configurator, MarketStatusbar, ShellHeader], + templateUrl: './trading-shell.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TradingShell { + readonly devMode = inject(TradingBenchmarkController).devMode + readonly sidebarOpen = signal(true) + readonly toggleSidebar = () => this.sidebarOpen.update((open) => !open) +} diff --git a/examples/angular/realtime-trading/src/app/table/current-trading-table.ts b/examples/angular/realtime-trading/src/app/table/current-trading-table.ts new file mode 100644 index 0000000000..e026e7fcda --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/current-trading-table.ts @@ -0,0 +1,108 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + untracked, + viewChild, +} from '@angular/core' +import { + FlexRender, + createSortedRowModel, + injectTable, + stockFeatures, + tableFeatures, +} from '@tanstack/angular-table' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark.controller' +import { createTradingColumns } from './table-config/trading-columns' +import { TradingTableInteractionController } from './table-interactions' +import { injectTradingTableInitialFit } from './trading-table-initial-fit' +import { TradingGridCellDirective } from './view/trading-grid-cell.directive' +import { TradingGridSelectionDirective } from './view/trading-grid-selection.directive' +import { TradingHeaderCell } from './view/trading-header-cell' +import { TradingTableBenchmarkDirective } from './view/trading-table-benchmark.directive' +import { + TRADING_ROW_HEIGHT, + injectTradingRowVirtualizer, +} from './trading-row-virtualizer' +import type { ElementRef } from '@angular/core' +import type { MarketQuote } from '../feed/market-data' +import type { RendererMode } from './table-config/trading-column-types' +import type { VirtualScrollMode } from './trading-row-virtualizer' + +const features = tableFeatures({ + ...stockFeatures, + sortedRowModel: createSortedRowModel(), +}) + +@Component({ + selector: 'app-current-trading-table', + imports: [ + FlexRender, + TradingGridCellDirective, + TradingGridSelectionDirective, + TradingHeaderCell, + TradingTableBenchmarkDirective, + ], + templateUrl: './table-v9.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CurrentTradingTable { + readonly #controller = inject(TradingBenchmarkController) + readonly quotes = input>([]) + readonly rendererMode = input.required() + readonly selectedSymbol = input(null) + readonly virtualScrollMode = input.required() + readonly symbolSelected = output() + readonly tanStackScrollContainer = viewChild>( + 'tanStackScrollContainer', + ) + readonly rowHeight = TRADING_ROW_HEIGHT + readonly interactions = new TradingTableInteractionController() + + readonly columns = createTradingColumns({ + rendererMode: () => this.rendererMode(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = injectTable(() => ({ + data: this.quotes(), + columns: this.columns, + features, + columnResizeMode: 'onChange' as const, + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + getRowId: (row) => row.id, + })) + readonly tableStyle = computed(() => { + void this.table.atoms.columnSizing.get() + void this.table.atoms.columnOrder.get() + return untracked(() => { + const styles: Record = { + width: `${this.table.getTotalSize()}px`, + } + for (const header of this.table.getFlatHeaders()) { + styles[`--header-${header.id}-size`] = `${header.getSize()}` + styles[`--col-${header.column.id}-size`] = `${header.column.getSize()}` + } + return styles + }) + }) + readonly rows = computed(() => this.table.getRowModel().rows) + readonly virtualization = injectTradingRowVirtualizer( + this.rows, + this.virtualScrollMode, + this.tanStackScrollContainer, + (count) => this.#controller.setRenderedRowCount(count), + ) + + readonly tableHeight = computed(() => this.rows().length * this.rowHeight) + readonly workerPending = computed(() => null) + readonly workerComputeMs = computed(() => null) + + constructor() { + injectTradingTableInitialFit(this.table, this.tanStackScrollContainer) + } +} diff --git a/examples/angular/realtime-trading/src/app/table/table-config/quote-cells.ts b/examples/angular/realtime-trading/src/app/table/table-config/quote-cells.ts new file mode 100644 index 0000000000..d7bd35a9a2 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/table-config/quote-cells.ts @@ -0,0 +1,258 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + input, + output, +} from '@angular/core' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +function trackLifecycle(): void { + quoteCellLifecycle.created++ + inject(DestroyRef).onDestroy(() => quoteCellLifecycle.destroyed++) +} + +@Component({ + selector: 'app-price-cell', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PriceCell { + readonly price = input.required() + readonly move = input.required() + readonly select = output() + readonly formattedPrice = computed(() => this.price().toFixed(2)) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-stable-move-cell', + template: ` + + {{ formattedMove() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StableMoveCell { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-percent-change-cell', + template: ` + + {{ formattedValue() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PercentChangeCell { + readonly value = input.required() + readonly formattedValue = computed( + () => `${this.value() >= 0 ? '+' : ''}${this.value().toFixed(2)}%`, + ) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-spread-cell', + template: ` + + {{ formattedSpread() }} + {{ basisPoints().toFixed(1) }} bp + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SpreadCell { + readonly bid = input.required() + readonly ask = input.required() + readonly spread = computed(() => Math.max(0, this.ask() - this.bid())) + readonly basisPoints = computed(() => { + const midpoint = (this.bid() + this.ask()) / 2 + return midpoint === 0 ? 0 : (this.spread() / midpoint) * 10_000 + }) + readonly formattedSpread = computed(() => this.spread().toFixed(2)) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-depth-cell', + template: ` +
+ + + + {{ formattedBidSize() }} + {{ formattedAskSize() }} + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DepthCell { + readonly bidSize = input.required() + readonly askSize = input.required() + readonly bidShare = computed(() => { + const total = this.bidSize() + this.askSize() + return total === 0 ? 50 : (this.bidSize() / total) * 100 + }) + readonly formattedBidSize = computed(() => + compactNumber.format(this.bidSize()), + ) + readonly formattedAskSize = computed(() => + compactNumber.format(this.askSize()), + ) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-quote-age-cell', + template: ` + + {{ formattedAge() }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuoteAgeCell { + readonly ageMs = input.required() + readonly formattedAge = computed(() => { + const age = this.ageMs() + return age < 1_000 + ? `${Math.round(age)} ms` + : `${(age / 1_000).toFixed(1)} s` + }) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-up-move-cell', + template: `▲ {{ formattedMove() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpMoveCell { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-down-move-cell', + template: `▼ {{ formattedMove() }}`, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DownMoveCell { + readonly move = input.required() + readonly formattedMove = computed(() => formatSigned(this.move())) + + constructor() { + trackLifecycle() + } +} + +@Component({ + selector: 'app-sparkline-cell', + template: ` + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SparklineCell { + readonly values = input.required>() + readonly rising = computed(() => { + const values = this.values() + return (values.at(-1) ?? 0) >= (values[0] ?? 0) + }) + readonly points = computed(() => { + const values = this.values() + const min = Math.min(...values) + const max = Math.max(...values) + const range = max - min || 1 + const denominator = Math.max(1, values.length - 1) + + return values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + }) + + constructor() { + trackLifecycle() + } +} + +function formatSigned(value: number): string { + const sign = value >= 0 ? '+' : '' + return `${sign}${value.toFixed(2)}` +} diff --git a/examples/angular/realtime-trading/src/app/table/table-config/trading-column-types.ts b/examples/angular/realtime-trading/src/app/table/table-config/trading-column-types.ts new file mode 100644 index 0000000000..dc6e83a0b2 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/table-config/trading-column-types.ts @@ -0,0 +1,8 @@ +export type RendererMode = 'stable' | 'swap' + +export interface TradingColumnState { + rendererMode: () => RendererMode + selectSymbol: (symbol: string) => void +} + +export const TRADING_COLUMN_COUNT = 14 diff --git a/examples/angular/realtime-trading/src/app/table/table-config/trading-columns.ts b/examples/angular/realtime-trading/src/app/table/table-config/trading-columns.ts new file mode 100644 index 0000000000..cdad8d3657 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/table-config/trading-columns.ts @@ -0,0 +1,186 @@ +import { flexRenderComponent } from '@tanstack/angular-table' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, +} from './quote-cells' +import type { ColumnDef, TableFeatures } from '@tanstack/angular-table' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingColumnState } from './trading-column-types' + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function createTradingColumns( + state: TradingColumnState, +): Array> { + return [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => row.original.venue, + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => row.original.company, + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + cell: ({ row }) => row.original.symbol, + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + cell: ({ row }) => { + const change = getDayChange(row.original) + return flexRenderComponent(PriceCell, { + inputs: { price: row.original.price, move: change }, + outputs: { + select: () => state.selectSymbol(row.original.symbol), + }, + }) + }, + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: (row) => getDayChange(row), + cell: ({ row }) => { + const move = getDayChange(row.original) + if (state.rendererMode() === 'stable') { + return flexRenderComponent(StableMoveCell, { + inputs: { move }, + }) + } + return flexRenderComponent(move >= 0 ? UpMoveCell : DownMoveCell, { + inputs: { move }, + }) + }, + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: (row) => getDayChangePercent(row), + cell: ({ row }) => + flexRenderComponent(PercentChangeCell, { + inputs: { value: getDayChangePercent(row.original) }, + }), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => row.original.bid.toFixed(2), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => compactFormatter.format(row.original.bidSize), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => row.original.ask.toFixed(2), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => compactFormatter.format(row.original.askSize), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => row.original.open.toFixed(2), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => row.original.high.toFixed(2), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => row.original.low.toFixed(2), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + flexRenderComponent(SparklineCell, { + inputs: { values: row.original.history }, + }), + }, + ], + }, + ] +} + +function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/angular/realtime-trading/src/app/table/table-interactions.ts b/examples/angular/realtime-trading/src/app/table/table-interactions.ts new file mode 100644 index 0000000000..24d43b8138 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/table-interactions.ts @@ -0,0 +1,181 @@ +import { signal } from '@angular/core' + +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +export interface ColumnOrderTable { + getVisibleLeafColumns: () => Array<{ id: string }> + setColumnOrder: (columnIds: Array) => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} + +export class TradingTableInteractionController { + readonly draggedColumnId = signal(null) + readonly dropTargetColumnId = signal(null) + + startColumnDrag(event: DragEvent, columnId: string): void { + this.draggedColumnId.set(columnId) + event.dataTransfer?.setData('text/plain', columnId) + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move' + } + + dragOverColumn(event: DragEvent, targetId: string): void { + event.preventDefault() + if (targetId === this.draggedColumnId()) { + this.dropTargetColumnId.set(null) + return + } + this.dropTargetColumnId.set(targetId) + } + + dropColumn( + table: ColumnOrderTable, + event: DragEvent, + targetId: string, + ): void { + event.preventDefault() + const sourceId = + event.dataTransfer?.getData('text/plain') || this.draggedColumnId() + if (!sourceId) { + this.clearColumnDrag() + return + } + + table.setColumnOrder( + reorderColumnIds( + table.getVisibleLeafColumns().map((column) => column.id), + sourceId, + targetId, + ), + ) + this.clearColumnDrag() + } + + endColumnDrag(): void { + this.clearColumnDrag() + } + + private clearColumnDrag(): void { + this.draggedColumnId.set(null) + this.dropTargetColumnId.set(null) + } + + selectRow( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, + ): void { + selectRowFromPointer(table, row, event) + } + + navigateCells(table: CellNavigationTable, event: KeyboardEvent): void { + handleCellNavigation(table, event) + } + + sortIndicator(direction: false | 'asc' | 'desc'): string { + return sortIndicator(direction) + } + + sortAriaValue( + direction: false | 'asc' | 'desc', + ): 'ascending' | 'descending' | 'none' { + return sortAriaValue(direction) + } +} diff --git a/examples/angular/realtime-trading/src/app/table/table-v9.html b/examples/angular/realtime-trading/src/app/table/table-v9.html new file mode 100644 index 0000000000..18219c4a25 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/table-v9.html @@ -0,0 +1,136 @@ +@switch (virtualScrollMode()) { + @case ('tanstack') { +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + @for (virtualRow of virtualization.tanStackVirtualRows(); track virtualRow.key) { + @if (rows()[virtualRow.index]; as row) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + } + +
+ + {{ renderCell }} + +
+
+ } + @default { +
+ + + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + + } + + } + + + @for (row of rows(); track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ renderCell }} + +
+
+ } +} + +@if (virtualScrollMode() !== 'none') { +
+ + TanStack · Total · {{ rows().length }} rows · + {{ table.getVisibleLeafColumns().length }} columns + + @if (virtualization.visibleRange(); as visibleRange) { + + Current · rows {{ visibleRange.start }}..{{ visibleRange.end }} + + } @else { + Current · rows — + } +
+} diff --git a/examples/angular/realtime-trading/src/app/table/trading-row-virtualizer.ts b/examples/angular/realtime-trading/src/app/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..122e4d1b6f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/trading-row-virtualizer.ts @@ -0,0 +1,115 @@ +import { + ChangeDetectorRef, + DestroyRef, + computed, + effect, + inject, +} from '@angular/core' +import { injectVirtualizer } from '@tanstack/angular-virtual' +import type { ElementRef, Signal } from '@angular/core' + +export const TRADING_ROW_HEIGHT = 32 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +const TANSTACK_OVERSCAN = 10 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} + +interface TradingVirtualRow { + id: string +} + +function injectLocalDomFlush() { + const changeDetectorRef = inject(ChangeDetectorRef) + const destroyRef = inject(DestroyRef) + const state = { + destroyed: false, + queued: false, + } + + destroyRef.onDestroy(() => { + state.destroyed = true + }) + + return () => { + if (state.queued || state.destroyed) return + + state.queued = true + queueMicrotask(() => { + state.queued = false + if (!state.destroyed) { + changeDetectorRef.detectChanges() + } + }) + } +} + +export function injectTradingRowVirtualizer( + rows: Signal>, + mode: Signal, + tanStackScrollContainer: Signal | undefined>, + reportRenderedRowCount: (count: number) => void, +) { + const scheduleLocalDomFlush = injectLocalDomFlush() + const rowVirtualizer = injectVirtualizer( + () => ({ + count: rows().length, + scrollElement: tanStackScrollContainer()?.nativeElement, + estimateSize: () => TRADING_ROW_HEIGHT, + getItemKey: (index) => rows()[index]?.id ?? index, + overscan: TANSTACK_OVERSCAN, + enabled: mode() === 'tanstack', + useCachedMeasurements: true, + useApplicationRefTick: false, + onChange: scheduleLocalDomFlush, + }), + ) + + const tanStackVirtualRows = rowVirtualizer.getVirtualItems + const tanStackTotalSize = rowVirtualizer.getTotalSize + const visibleRange = computed(() => { + const rowCount = rows().length + const range = rowVirtualizer.range() + + if (mode() !== 'tanstack' || rowCount === 0 || range === null) { + return null + } + + const lastRowIndex = rowCount - 1 + const start = Math.min(range.startIndex, lastRowIndex) + + return { + start, + end: Math.min(Math.max(start, range.endIndex), lastRowIndex), + } + }) + const renderedRowCount = computed(() => { + if (mode() === 'tanstack') { + return tanStackVirtualRows().length + } + return rows().length + }) + + effect(() => reportRenderedRowCount(renderedRowCount())) + + return { + rowVirtualizer, + tanStackVirtualRows, + tanStackTotalSize, + visibleRange, + renderedRowCount, + } +} diff --git a/examples/angular/realtime-trading/src/app/table/trading-table-initial-fit.ts b/examples/angular/realtime-trading/src/app/table/trading-table-initial-fit.ts new file mode 100644 index 0000000000..0786f26529 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/trading-table-initial-fit.ts @@ -0,0 +1,63 @@ +import { DestroyRef, afterNextRender, inject } from '@angular/core' +import type { ElementRef, Signal } from '@angular/core' + +interface FitColumn { + getSize: () => number + id: string +} + +interface FitTable { + atoms: { + columnResizing: { + subscribe: ( + listener: (state: { isResizingColumn: false | string }) => void, + ) => { unsubscribe: () => void } + } + } + getTotalSize: () => number + getVisibleLeafColumns: () => Array + setColumnSizing: (sizes: Record) => void +} + +export function injectTradingTableInitialFit( + table: FitTable, + scrollContainer: Signal | undefined>, +): void { + const destroyRef = inject(DestroyRef) + const runtime = { manuallyResized: false } + + afterNextRender(() => { + const element = scrollContainer()?.nativeElement + if (!element) return + + const fitAvailableWidth = () => { + if (runtime.manuallyResized) return + const currentWidth = table.getTotalSize() + const availableWidth = element.clientWidth + if (availableWidth <= currentWidth + 1 || currentWidth <= 0) return + + const ratio = availableWidth / currentWidth + table.setColumnSizing( + Object.fromEntries( + table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + + const resizeObserver = new ResizeObserver(fitAvailableWidth) + const resizingSubscription = table.atoms.columnResizing.subscribe( + (state) => { + if (state.isResizingColumn !== false) runtime.manuallyResized = true + }, + ) + resizeObserver.observe(element) + fitAvailableWidth() + + destroyRef.onDestroy(() => { + resizeObserver.disconnect() + resizingSubscription.unsubscribe() + }) + }) +} diff --git a/examples/angular/realtime-trading/src/app/table/view/trading-grid-cell.directive.ts b/examples/angular/realtime-trading/src/app/table/view/trading-grid-cell.directive.ts new file mode 100644 index 0000000000..150b65c5d2 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/view/trading-grid-cell.directive.ts @@ -0,0 +1,42 @@ +import { Directive, computed, inject, input } from '@angular/core' +import { TradingGridSelectionDirective } from './trading-grid-selection.directive' +import type { Cell, TableFeatures } from '@tanstack/angular-table' +import type { MarketQuote } from '../../feed/market-data' + +type TradingCell = Cell + +@Directive({ + selector: 'td[appTradingGridCell]', + host: { + '[style.width]': 'staticHostState().width', + '[attr.data-column-id]': 'staticHostState().columnId', + '[attr.data-cell-focused]': "selectionHostState().focused ? 'true' : null", + '[attr.data-selection-top]': "selectionHostState().top ? 'true' : null", + '[attr.data-selection-right]': "selectionHostState().right ? 'true' : null", + '[attr.data-selection-bottom]': + "selectionHostState().bottom ? 'true' : null", + '[attr.data-selection-left]': "selectionHostState().left ? 'true' : null", + '[attr.tabindex]': 'selectionHostState().tabIndex', + '[attr.aria-selected]': 'selectionHostState().selected', + }, +}) +export class TradingGridCellDirective { + readonly #selection = inject(TradingGridSelectionDirective) + + readonly cell = input.required({ + alias: 'appTradingGridCell', + }) + + readonly staticHostState = computed(() => { + const columnId = this.cell().column.id + + return { + width: `calc(var(--col-${columnId}-size) * 1px)`, + columnId, + } + }) + + readonly selectionHostState = computed(() => { + return this.#selection.readCellSelectionState(this.cell()) + }) +} diff --git a/examples/angular/realtime-trading/src/app/table/view/trading-grid-selection.directive.ts b/examples/angular/realtime-trading/src/app/table/view/trading-grid-selection.directive.ts new file mode 100644 index 0000000000..f58c6f6b4c --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/view/trading-grid-selection.directive.ts @@ -0,0 +1,98 @@ +import { Directive, input, output } from '@angular/core' +import type { Cell, Table, TableFeatures } from '@tanstack/angular-table' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingTableInteractionController } from '../table-interactions' + +type TradingCell = Cell +type TradingTable = Table + +interface SelectionCellTarget { + readonly element: HTMLTableCellElement + readonly cell: TradingCell +} + +@Directive({ + selector: 'table[appTradingGridSelection]', + host: { + '(mousedown)': 'handleMouseDown($event)', + '(mousemove)': 'handleMouseMove($event)', + '(mouseleave)': 'resetPointerCell()', + '(click)': 'handleClick($event)', + }, +}) +export class TradingGridSelectionDirective { + readonly table = input.required({ + alias: 'appTradingGridSelection', + }) + readonly interactions = input.required() + readonly symbolSelected = output() + + #lastPointerCell: HTMLTableCellElement | null = null + + readCellSelectionState(cell: TradingCell) { + this.table().atoms.cellSelection.get() + const edges = cell.getSelectionEdges() + + return { + selected: cell.getIsSelected(), + focused: cell.getIsFocused(), + top: edges.top, + right: edges.right, + bottom: edges.bottom, + left: edges.left, + tabIndex: cell.getTabIndex(), + } + } + + handleMouseDown(event: MouseEvent): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + this.symbolSelected.emit(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handleMouseMove(event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.#lastPointerCell = null + return + } + + const target = this.#findCellTarget(event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + handleClick(event: MouseEvent): void { + const target = this.#findCellTarget(event.composedPath()) + if (!target) return + this.interactions().selectRow(this.table(), target.cell.row, event) + } + + #findCellTarget(path: Array): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = this.table().getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} diff --git a/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.html b/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.html new file mode 100644 index 0000000000..4a79e34760 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.html @@ -0,0 +1,53 @@ +@if (!header().isPlaceholder) { + @if (isGroup()) { + + {{ headerCell }} + + } @else { +
+ + +
+ @if (header().column.getCanResize()) { + + } + } +} diff --git a/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.ts b/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.ts new file mode 100644 index 0000000000..d0fc7df5d8 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/view/trading-header-cell.ts @@ -0,0 +1,64 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core' +import { FlexRender } from '@tanstack/angular-table' +import type { Header, TableFeatures } from '@tanstack/angular-table' +import type { MarketQuote } from '../../feed/market-data' +import type { + ColumnOrderTable, + TradingTableInteractionController, +} from '../table-interactions' + +type TradingHeader = Header + +@Component({ + selector: 'th[appTradingHeaderCell]', + imports: [FlexRender], + templateUrl: './trading-header-cell.html', + host: { + '[attr.colspan]': 'header().colSpan', + '[attr.aria-sort]': 'ariaSort()', + '[class.column-group-header]': 'isGroup()', + '[class.numeric-header]': 'isNumeric()', + '[class.is-column-dragging]': + 'interactions().draggedColumnId() === header().column.id', + '[class.is-column-drop-target]': + 'interactions().dropTargetColumnId() === header().column.id', + '[style.width]': 'width()', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TradingHeaderCell { + readonly header = input.required({ + alias: 'appTradingHeaderCell', + }) + readonly table = input.required() + readonly interactions = input.required() + + readonly isGroup = computed(() => this.header().subHeaders.length > 0) + readonly isNumeric = computed( + () => !this.isGroup() && !isTextColumn(this.header().column.id), + ) + readonly width = computed( + () => `calc(var(--header-${this.header().id}-size) * 1px)`, + ) + readonly sorted = computed(() => this.header().column.getIsSorted()) + readonly ariaSort = computed(() => + this.isGroup() ? null : this.interactions().sortAriaValue(this.sorted()), + ) + + startColumnDrag(event: DragEvent): void { + this.interactions().startColumnDrag(event, this.header().column.id) + } + + dropColumn(event: DragEvent): void { + this.interactions().dropColumn(this.table(), event, this.header().column.id) + } +} + +function isTextColumn(columnId: string): boolean { + return columnId === 'market' || columnId === 'name' || columnId === 'symbol' +} diff --git a/examples/angular/realtime-trading/src/app/table/view/trading-table-benchmark.directive.ts b/examples/angular/realtime-trading/src/app/table/view/trading-table-benchmark.directive.ts new file mode 100644 index 0000000000..75b3de8586 --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/view/trading-table-benchmark.directive.ts @@ -0,0 +1,25 @@ +import { DestroyRef, Directive, ElementRef, inject } from '@angular/core' +import { TradingBenchmarkController } from '../../benchmark/trading-benchmark.controller' + +@Directive({ + selector: 'tbody[appTradingTableBenchmark]', +}) +export class TradingTableBenchmarkDirective { + readonly #controller = inject(TradingBenchmarkController) + readonly #element = inject>(ElementRef) + readonly #observer = new MutationObserver((records) => { + this.#controller.recordDomMutations(records.length) + }) + + constructor() { + this.#controller.resetDomMutations() + this.#observer.observe(this.#element.nativeElement, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + inject(DestroyRef).onDestroy(() => this.#observer.disconnect()) + } +} diff --git a/examples/angular/realtime-trading/src/app/table/worker/table-row-model.worker.ts b/examples/angular/realtime-trading/src/app/table/worker/table-row-model.worker.ts new file mode 100644 index 0000000000..4f04f7d84f --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/worker/table-row-model.worker.ts @@ -0,0 +1,59 @@ +import { + createFilteredRowModel, + createSortedRowModel, + stockFeatures, + tableFeatures, +} from '@tanstack/angular-table' +import { + initTableWorker, + workerRowModelsFeature, +} from '@tanstack/angular-table/experimental-worker-plugin' +import type { ColumnDef } from '@tanstack/angular-table' +import type { MarketQuote } from '../../feed/market-data' + +const workerFeatures = tableFeatures({ + ...stockFeatures, + workerRowModelsFeature, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), +}) + +// The worker only computes row models, so it needs portable accessors but none +// of the Angular render components used by the visible table. +const workerColumns: Array< + ColumnDef +> = [ + { id: 'market', accessorFn: (row) => row.venue }, + { id: 'name', accessorFn: (row) => row.company }, + { id: 'symbol', accessorFn: (row) => row.symbol }, + { id: 'price', accessorFn: (row) => row.price }, + { + id: 'change', + accessorFn: (row) => row.price - row.previousClose, + }, + { + id: 'changePercent', + accessorFn: (row) => + row.previousClose === 0 + ? 0 + : ((row.price - row.previousClose) / row.previousClose) * 100, + }, + { id: 'bid', accessorFn: (row) => row.bid }, + { id: 'bidSize', accessorFn: (row) => row.bidSize }, + { id: 'ask', accessorFn: (row) => row.ask }, + { id: 'askSize', accessorFn: (row) => row.askSize }, + { id: 'open', accessorFn: (row) => row.open }, + { id: 'high', accessorFn: (row) => row.high }, + { id: 'low', accessorFn: (row) => row.low }, + { + id: 'history', + accessorFn: (row) => row.history, + enableSorting: false, + }, +] + +initTableWorker({ + features: workerFeatures, + columns: workerColumns, + getRowId: (row) => row.id, +}) diff --git a/examples/angular/realtime-trading/src/app/table/worker/worker-trading-table.ts b/examples/angular/realtime-trading/src/app/table/worker/worker-trading-table.ts new file mode 100644 index 0000000000..ddea2f63ae --- /dev/null +++ b/examples/angular/realtime-trading/src/app/table/worker/worker-trading-table.ts @@ -0,0 +1,135 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + input, + output, + untracked, + viewChild, +} from '@angular/core' +import { + FlexRender, + injectTable, + stockFeatures, + tableFeatures, +} from '@tanstack/angular-table' +import { + createTableWorker, + createWorkerRowModel, + workerRowModelsFeature, +} from '@tanstack/angular-table/experimental-worker-plugin' +import { TradingBenchmarkController } from '../../benchmark/trading-benchmark.controller' +import { createTradingColumns } from '../table-config/trading-columns' +import { TradingTableInteractionController } from '../table-interactions' +import { injectTradingTableInitialFit } from '../trading-table-initial-fit' +import { TradingGridCellDirective } from '../view/trading-grid-cell.directive' +import { TradingGridSelectionDirective } from '../view/trading-grid-selection.directive' +import { TradingHeaderCell } from '../view/trading-header-cell' +import { TradingTableBenchmarkDirective } from '../view/trading-table-benchmark.directive' +import { + TRADING_ROW_HEIGHT, + injectTradingRowVirtualizer, +} from '../trading-row-virtualizer' +import type { ElementRef } from '@angular/core' +import type { MarketQuote } from '../../feed/market-data' +import type { RendererMode } from '../table-config/trading-column-types' +import type { VirtualScrollMode } from '../trading-row-virtualizer' + +function createWorkerTableRuntime() { + const worker = createTableWorker({ + createWorker: () => + new Worker(new URL('./table-row-model.worker', import.meta.url), { + type: 'module', + }), + }) + const features = tableFeatures({ + ...stockFeatures, + workerRowModelsFeature, + filteredRowModel: createWorkerRowModel(worker, 'filtered'), + sortedRowModel: createWorkerRowModel(worker, 'sorted'), + }) + + return { worker, features } +} + +@Component({ + selector: 'app-worker-trading-table', + imports: [ + FlexRender, + TradingGridCellDirective, + TradingGridSelectionDirective, + TradingHeaderCell, + TradingTableBenchmarkDirective, + ], + templateUrl: '../table-v9.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkerTradingTable { + readonly #controller = inject(TradingBenchmarkController) + readonly #destroyRef = inject(DestroyRef) + readonly #workerRuntime = createWorkerTableRuntime() + readonly quotes = input>([]) + readonly rendererMode = input.required() + readonly selectedSymbol = input(null) + readonly virtualScrollMode = input.required() + readonly symbolSelected = output() + readonly tanStackScrollContainer = viewChild>( + 'tanStackScrollContainer', + ) + readonly rowHeight = TRADING_ROW_HEIGHT + readonly interactions = new TradingTableInteractionController() + + readonly columns = createTradingColumns({ + rendererMode: () => this.rendererMode(), + selectSymbol: (symbol) => this.symbolSelected.emit(symbol), + }) + + readonly table = injectTable(() => ({ + data: this.quotes(), + columns: this.columns, + features: this.#workerRuntime.features, + columnResizeMode: 'onChange' as const, + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + getRowId: (row) => row.id, + })) + readonly tableStyle = computed(() => { + void this.table.atoms.columnSizing.get() + void this.table.atoms.columnOrder.get() + return untracked(() => { + const styles: Record = { + width: `${this.table.getTotalSize()}px`, + } + for (const header of this.table.getFlatHeaders()) { + styles[`--header-${header.id}-size`] = `${header.getSize()}` + styles[`--col-${header.column.id}-size`] = `${header.column.getSize()}` + } + return styles + }) + }) + readonly rows = computed(() => this.table.getRowModel().rows) + readonly virtualization = injectTradingRowVirtualizer( + this.rows, + this.virtualScrollMode, + this.tanStackScrollContainer, + (count) => this.#controller.setRenderedRowCount(count), + ) + + readonly workerPending = computed(() => { + return String(this.table.atoms.workerRowModels.get().isPending) + }) + + readonly workerComputeMs = computed(() => { + const computeMs = this.table.atoms.workerRowModels.get().lastComputeMs + return computeMs === undefined ? null : computeMs.toFixed(3) + }) + + readonly tableHeight = computed(() => this.rows().length * this.rowHeight) + + constructor() { + injectTradingTableInitialFit(this.table, this.tanStackScrollContainer) + this.#destroyRef.onDestroy(() => this.#workerRuntime.worker.terminate()) + } +} diff --git a/examples/angular/realtime-trading/src/index.html b/examples/angular/realtime-trading/src/index.html new file mode 100644 index 0000000000..3122a836db --- /dev/null +++ b/examples/angular/realtime-trading/src/index.html @@ -0,0 +1,17 @@ + + + + + Angular Real-time Trading flexRender Lab + + + + + + + + + diff --git a/examples/angular/realtime-trading/src/main.ts b/examples/angular/realtime-trading/src/main.ts new file mode 100644 index 0000000000..c9b5d07d64 --- /dev/null +++ b/examples/angular/realtime-trading/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser' +import { App } from './app/app' +import { appConfig } from './app/app.config' + +bootstrapApplication(App, appConfig).catch((error) => console.error(error)) diff --git a/examples/angular/realtime-trading/src/styles.css b/examples/angular/realtime-trading/src/styles.css new file mode 100644 index 0000000000..113476ec65 --- /dev/null +++ b/examples/angular/realtime-trading/src/styles.css @@ -0,0 +1,1088 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +app-current-trading-table, +app-worker-trading-table { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr.virtual-table-row.is-even-row { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +app-depth-cell, +app-stable-move-cell, +app-up-move-cell, +app-down-move-cell, +app-percent-change-cell, +app-sparkline-cell { + display: block; + width: 100%; +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +.diagnostic-note { + margin: 0.5rem 0 0; + color: var(--muted); + font-size: 0.56rem; + line-height: 1.45; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts b/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..d107f906fa --- /dev/null +++ b/examples/angular/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,239 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + + return errors +} + +test('runs the Angular realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + + const selectionStart = table.locator('tbody tr').nth(0).locator('td').nth(1) + const selectionEnd = table.locator('tbody tr').nth(2).locator('td').nth(3) + const selectedSymbol = await table + .locator('tbody tr') + .nth(0) + .getAttribute('data-symbol') + const selectionStartBox = await selectionStart.boundingBox() + const selectionEndBox = await selectionEnd.boundingBox() + if (!selectionStartBox || !selectionEndBox) { + throw new Error('Expected visible cells for the drag-selection check') + } + await page.mouse.move( + selectionStartBox.x + selectionStartBox.width / 2, + selectionStartBox.y + selectionStartBox.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + selectionEndBox.x + selectionEndBox.width / 2, + selectionEndBox.y + selectionEndBox.height / 2, + { steps: 4 }, + ) + await page.mouse.up() + await expect(table.locator('td[aria-selected="true"]')).toHaveCount(9) + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="1500"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('long-frame-count')).toHaveText( + /^\s*(?:N\/A|\d+)\s*$/, + ) + + const tableWorker = page.getByTestId('table-worker-toggle') + await expect(tableWorker).toBeEnabled() + await tableWorker.check() + await expect(page.getByText('ROW MODEL WORKER ON')).toBeVisible() + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await instrumentCount.selectOption('100') + await expect(table.locator('tbody tr')).toHaveCount(100) + await page.getByTestId('feed-toggle').click() + await expect + .poll(() => + page + .locator('app-worker-trading-table [data-table-worker-pending]') + .getAttribute('data-table-worker-compute-ms'), + ) + .not.toBeNull() + await expect( + page.locator('app-worker-trading-table [data-table-worker-pending]'), + ).toHaveAttribute('data-table-worker-pending', 'false') + await page.getByTestId('feed-toggle').click() + await tableWorker.uncheck() + + await expect(tableWorker).toBeEnabled() + await instrumentCount.selectOption('1500') + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + const firstPrice = table.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + await instrumentCount.selectOption('100') + await expect(table.locator('tbody tr')).toHaveCount(100) + + await page + .getByLabel( + 'Swap Tick component A ↔ B destroy and recreate when direction changes', + ) + .check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/angular/realtime-trading/tsconfig.app.json b/examples/angular/realtime-trading/tsconfig.app.json new file mode 100644 index 0000000000..8426ad9558 --- /dev/null +++ b/examples/angular/realtime-trading/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] +} diff --git a/examples/angular/realtime-trading/tsconfig.json b/examples/angular/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..9ff0bce067 --- /dev/null +++ b/examples/angular/realtime-trading/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": ["**/*.ts", "tests/e2e"] +} diff --git a/examples/ember/realtime-trading/README.md b/examples/ember/realtime-trading/README.md new file mode 100644 index 0000000000..bca583ee1d --- /dev/null +++ b/examples/ember/realtime-trading/README.md @@ -0,0 +1,154 @@ +# Ember realtime trading benchmark + +This standalone example exercises the current TanStack Ember Table adapter +with a high-frequency worker feed, immutable snapshots, interactive columns, +Glimmer quote components, Virtual Core, and browser diagnostics. It is a +repeatable rendering stress workload, not an exchange or network benchmark. + +## Run and verify + +```bash +pnpm --dir examples/ember/realtime-trading dev +``` + +Open `http://localhost:7785`. + +```bash +pnpm --dir examples/ember/realtime-trading test:types +pnpm --dir examples/ember/realtime-trading build +pnpm --dir examples/ember/realtime-trading test:e2e +``` + +This example currently has no separate `lint` package script. Use a production +build for performance recordings. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `app/feed/` | Market model, instrument universe, configuration, immutable update helpers, and Ember-native tracked controller. | +| `app/feed/worker/` | Typed protocol, deterministic engine, and module worker. | +| `app/benchmark/` | Browser monitor and benchmark controller. | +| `app/components/shell/` | GTS header, metrics, configurator, diagnostics, selected instrument, status bar, and shell layout. | +| `app/components/table/` | Trading table Glimmer component. | +| `app/table/table-config/` | Grouped columns and custom GTS quote components. | +| `app/table/` | Table construction, features, delegated interactions, and Virtual Core integration. | +| `app/utils/subscriptions.ts` | Owner-bound destruction cleanup helper. | +| `app/templates/application.gts` | Creates feed/benchmark controllers and composes the shell. | + +`MarketFeedController` owns worker/feed state. `TradingBenchmarkController` +observes feed lifecycle callbacks and owns diagnostic/view state. The +application passes stable controllers to shell/table components; Glimmer +components read the controller's `@tracked` properties directly. `quotes` is +its own high-frequency tracked property; feed status and each configuration +value are separate tracked properties. Derived benchmark values use `@cached`. +There is no foreign Store subscription or mirrored component state, and the +root does not proxy every quote or metric as application state. + +## Feed and worker pipeline + +Defaults are 100 instruments, 10K generated samples/s, 20 ms delivery, enabled +intraday charts, and 16 ms chart sampling. + +Mutable quote state is private to the worker. A deterministic 16 ms budget loop +generates samples, a row-indexed `Map` coalesces repeated changes, and a separate +timer publishes the latest unique rows. The main thread creates a new outer +array and replaces only changed rows; untouched rows and unsampled history +arrays retain references. Session IDs reject stale messages after resets or +instrument-count changes. + +- **Synthetic quote workload** is worker-generated samples/s, not Glimmer + renders, events, or messages. +- **Worker delivery interval** controls coalesced message cadence; 20 ms targets + around 50 messages/s. +- **Row updates** is the number of unique immutable rows applied. +- **Message samples** is the generated work represented by the latest message. + +Intraday history uses its own cadence. The 25K burst deliberately publishes one +heavy batch. The worker resembles an upstream stream, without network latency. + +## Ember table architecture + +The grid has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart. It includes sorting/filtering, on-change resizing, +double-click reset, drag ordering, CSS hover, row selection, drag cell ranges, +keyboard navigation, and Price/Move/Percent/Sparkline Glimmer components. + +`flexRenderComponent` is used for actual component cells. Stable instrument IDs +back row identity. One table/body interaction path resolves cells through +`event.composedPath()` and data attributes instead of allocating handlers on +every cell. Column widths are CSS variables updated on sizing/order; a +`ResizeObserver` performs initial fitting until manual resize. The +CSS-variable string is an `@cached` getter that reads Ember-reactive table +sizing and order, so the first fit and later table updates reach the DOM +without an imperative Store subscription. A/B move component swapping is an +explicit lifecycle stress mode. + +## Virtualization + +- Below 200 rows, automatic mode uses Full DOM, while Virtual is selectable. +- From 200 through 1,499 rows, automatic mode uses TanStack Virtual and Full DOM + remains selectable. +- At 1,500 rows or more, Virtual is forced and its control is disabled. + +The local Virtual Core integration owns one instance with 32 px estimates, +10-row overscan, stable row IDs, transformed rows, and a spacer body. Instance +notifications invalidate only the table range, and the footer reads that range. +Both Full DOM and virtual rows use `content-visibility: auto`; Full DOM still +creates every Glimmer row/cell. + +## Performance decisions + +- worker-side generation and pre-message coalescing; +- immutable structural sharing for rows/history; +- direct `@tracked` feed/view state and `@cached` derived state; +- stable table and virtual row identity; +- componentized GTS shell/table/cell boundaries; +- delegated pointer input and CSS hover; +- CSS variables for width propagation; +- opt-in lifecycle churn and independently sampled charts; +- virtual mounting for larger row counts; +- explicit destructor cleanup and lower-frequency metric publication. + +The outer array intentionally changes for an immutable batch. Structural +sharing reduces cell work, but sorting/filtering can still trigger row-model +processing when data changes. + +## Diagnostics and interpretation + +The compact **Live health** section lives in the configurator and reports the +estimated frame callback rate over 1 second, average market-mutation-to-DOM- +commit latency over 3 seconds, cumulative long animation frames, and throughput +as changed rows plus applied snapshots per second. Detailed diagnostics retain +worker samples/messages, state applies, DOM commits, rolling 10-second p95/max +commit latency, cumulative slow commits, mounted hosts, component +lifecycle/execution, row-model timing, DOM mutation records, and heap. + +The frame value counts `requestAnimationFrame` callbacks, not GPU-presented +frames, so its ceiling follows the display refresh rate. Renderer callbacks and +DOM mutations are different measurements. `MutationObserver` counts delivered +records, not individual DOM operations, and has overhead on a hot subtree; it +only observes text, child-list, and `class`/`style` changes to reduce selection +noise. Heap is a Chromium-only, GC-sensitive point-in-time value. User Timing +timeline measures are sampled 1-in-20 while the in-memory latency calculation +keeps every commit, reducing profiler self-interference. Temporary heap growth +during component swapping is not a leak without post-GC retention. +The rAF loop only appends a timestamp; rolling aggregation and heap reads run at +the 500 ms metrics publication cadence. Mutation observation remains the most +intrusive diagnostic because the browser must create records for the observed +subtree. + +`Changed rows/s` sums the update array lengths delivered by each snapshot. +Symbols are deduplicated inside one message, but the same row can count again in +the next snapshot; it is applied row throughput, not distinct instruments per +second. + +## Standalone policy + +All instruments, feed, worker, benchmark, shell, styles, and table code are +copied into this directory intentionally. It remains independently runnable and +StackBlitz-friendly, so common implementation and README material is repeated +across adapters by design. + +The workspace resolves `@tanstack/ember-table` to the repository adapter while +the example package remains release-like. diff --git a/examples/ember/realtime-trading/app/app.css b/examples/ember/realtime-trading/app/app.css new file mode 100644 index 0000000000..82328b533e --- /dev/null +++ b/examples/ember/realtime-trading/app/app.css @@ -0,0 +1,1065 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +.diagnostic-note { + margin: 0.5rem 0 0; + color: var(--muted); + font-size: 0.56rem; + line-height: 1.45; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/ember/realtime-trading/app/app.ts b/examples/ember/realtime-trading/app/app.ts new file mode 100644 index 0000000000..253a0de562 --- /dev/null +++ b/examples/ember/realtime-trading/app/app.ts @@ -0,0 +1,22 @@ +/** + * Looking for services that come from addons? + * + * See: https://github.com/embroider-build/embroider/issues/2659 + * + * We currently don't support app-tree merging from libraries. + * + * For services, I highly recommend looking into either of + * - https://github.com/chancancode/ember-polaris-service- + * - https://ember-primitives.pages.dev/6-utils/createService.md + * - https://ember-primitives.pages.dev/6-utils/createAsyncService.md + */ +import Application from 'ember-strict-application-resolver' +import './app.css' + +export default class App extends Application { + modules = { + ...import.meta.glob('./router.*', { eager: true }), + ...import.meta.glob('./templates/**/*', { eager: true }), + ...import.meta.glob('./services/**/*', { eager: true }), + } +} diff --git a/examples/ember/realtime-trading/app/benchmark/benchmark-monitor.ts b/examples/ember/realtime-trading/app/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..ee174bcd53 --- /dev/null +++ b/examples/ember/realtime-trading/app/benchmark/benchmark-monitor.ts @@ -0,0 +1,402 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells.gts' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCallCount: 0 } +const USER_TIMING_SAMPLE_INTERVAL = 20 + +interface CommitLatencySample { + recordedAt: number + duration: number +} + +const AVERAGE_COMMIT_WINDOW_MS = 3_000 +const PERCENTILE_COMMIT_WINDOW_MS = 10_000 +const FRAME_RATE_WINDOW_MS = 1_000 + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCallCount++ + if (userTiming.measureCallCount % USER_TIMING_SAMPLE_INTERVAL !== 0) return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + frameTrackingStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + renderSamples: [] as Array, + frameTimestamps: [] as Array, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + tableRendersInSample: 0, + slowRenderCount: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + recordCompletedRender(): void { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + const renderEndedAt = performance.now() + const duration = renderEndedAt - runtime.pendingRenderStartedAt + runtime.renderSamples.push({ recordedAt: renderEndedAt, duration }) + if (duration > 16.7) runtime.slowRenderCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingRenderStartedAt, + renderEndedAt, + {}, + ) + runtime.pendingRenderStartedAt = null + runtime.tableRendersInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + this.#runtime.frameTimestamps.push(now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + runtime.renderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - PERCENTILE_COMMIT_WINDOW_MS, + ) + runtime.frameTimestamps = runtime.frameTimestamps.filter( + (timestamp) => timestamp >= now - FRAME_RATE_WINDOW_MS, + ) + const averageRenderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - AVERAGE_COMMIT_WINDOW_MS, + ) + const sortedRenderSamples = runtime.renderSamples + .map((sample) => sample.duration) + .sort((left, right) => left - right) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageRenderMs = + averageRenderSamples.length === 0 + ? 0 + : averageRenderSamples.reduce( + (sum, sample) => sum + sample.duration, + 0, + ) / averageRenderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: + (runtime.frameTimestamps.length / + Math.min( + FRAME_RATE_WINDOW_MS, + Math.max(1, now - runtime.frameTrackingStartedAt), + )) * + 1_000, + tableRendersPerSecond: + (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: runtime.slowRenderCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingRenderStartedAt = null + runtime.renderSamples = [] + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.tableRendersInSample = 0 + runtime.slowRenderCount = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} diff --git a/examples/ember/realtime-trading/app/benchmark/trading-benchmark-controller.ts b/examples/ember/realtime-trading/app/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..a5bbf0dd0a --- /dev/null +++ b/examples/ember/realtime-trading/app/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,126 @@ +import { cached, tracked } from '@glimmer/tracking' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +export class TradingBenchmarkController { + @tracked requestedVirtualScrollMode: VirtualScrollPreference = 'auto' + @tracked metrics: FeedMetrics = initialMetrics + @tracked mountedCells = 0 + @tracked selectedSymbol: string | null = null + @tracked rendererMode: RendererMode = 'stable' + @cached + get liveComponents(): number { + const metrics = this.metrics + return metrics.componentsCreated - metrics.componentsDestroyed + } + readonly longAnimationFramesSupported = longAnimationFramesSupported + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.selectedSymbol = null + }, + setRendererMode: (mode) => { + this.rendererMode = mode + }, + setVirtualScrollEnabled: (enabled) => { + if (this.feed.instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) { + return + } + this.requestedVirtualScrollMode = enabled ? 'tanstack' : 'none' + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.mountedCells) { + this.mountedCells = mountedCells + } + }, + selectSymbol: (symbol) => { + this.selectedSymbol = symbol + }, + resetMarket: () => { + this.monitor.reset() + this.metrics = { ...initialMetrics } + this.mountedCells = 0 + this.selectedSymbol = null + this.feed.actions.reset() + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markRenderPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordCompletedRender(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.metrics = metrics + } +} diff --git a/examples/ember/realtime-trading/app/components/shell/app-header.gts b/examples/ember/realtime-trading/app/components/shell/app-header.gts new file mode 100644 index 0000000000..017ccddfe6 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/app-header.gts @@ -0,0 +1,63 @@ +import Component from '@glimmer/component' +import { on } from '@ember/modifier' +import type { MarketFeedController } from '../../feed/market-feed-controller' + +interface Signature { + Args: { + feed: MarketFeedController + sidebarOpen: boolean + toggleSidebar: () => void + } +} + +export default class AppHeader extends Component { + get workerReady() { + return this.args.feed.workerReady + } + get running() { + return this.args.feed.running + } + get status() { + return !this.workerReady + ? 'FEED CONNECTING' + : this.running + ? 'FEED LIVE' + : 'FEED PAUSED' + } + +} + +const and = (left: boolean, right: boolean): boolean => left && right diff --git a/examples/ember/realtime-trading/app/components/shell/configurator-options.ts b/examples/ember/realtime-trading/app/components/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/ember/realtime-trading/app/components/shell/configurator.gts b/examples/ember/realtime-trading/app/components/shell/configurator.gts new file mode 100644 index 0000000000..8e3c229341 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/configurator.gts @@ -0,0 +1,227 @@ +import Component from '@glimmer/component' +import { on } from '@ember/modifier' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../../feed/feed-sample-rates' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../../table/trading-row-virtualizer' +import { configuratorOptions } from './configurator-options' +import Diagnostics from './diagnostics.gts' +import MetricsStrip from './metrics-strip.gts' +import SelectedInstrument from './selected-instrument.gts' +import type { MarketFeedController } from '../../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController; feed: MarketFeedController } +} +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export default class Configurator extends Component { + readonly feedSampleRateOptions = feedSampleRateOptions + readonly options = configuratorOptions + get running() { + return this.args.feed.running + } + get instrumentCount() { + return this.args.feed.instrumentCount + } + get targetTicksPerSecond() { + return this.args.feed.targetTicksPerSecond + } + get publishIntervalMs() { + return this.args.feed.publishIntervalMs + } + get updateSparklines() { + return this.args.feed.updateSparklines + } + get sparklineSampleIntervalMs() { + return this.args.feed.sparklineSampleIntervalMs + } + get rendererMode() { + return this.args.controller.rendererMode + } + + get virtualForced() { + return this.instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT + } + get virtualMode() { + return resolveVirtualScrollMode( + this.args.controller.requestedVirtualScrollMode, + this.instrumentCount, + ) + } + get sampleRateIndex() { + return feedSampleRateIndex(this.targetTicksPerSecond) + } + get sampleRateLabel() { + return `${rate.format(this.targetTicksPerSecond)} samples/s` + } + get virtualDescription() { + return this.virtualForced + ? 'TanStack Virtual is required and locked at 1,500 or more rows.' + : 'Full DOM is the default below 200 rows; TanStack Virtual is the default from 200 rows and remains selectable.' + } + + setInstrumentCount = (event: Event) => { + this.args.controller.actions.resetViewState() + this.args.feed.actions.setInstrumentCount(numberValue(event)) + } + setSampleRate = (event: Event) => { + this.args.feed.actions.setTargetRate(feedSampleRateAt(numberValue(event))) + } + setPublishInterval = (event: Event) => { + this.args.feed.actions.setPublishInterval(numberValue(event)) + } + setVirtualMode = (event: Event) => { + this.args.controller.actions.setVirtualScrollEnabled( + selectValue(event) === 'tanstack', + ) + } + setRendererMode = (event: Event) => { + this.args.controller.actions.setRendererMode( + checked(event) ? 'swap' : 'stable', + ) + } + setSparklineUpdates = (event: Event) => { + this.args.feed.actions.setSparklineUpdates(checked(event)) + } + setSparklineInterval = (event: Event) => { + this.args.feed.actions.setSparklineSampleInterval(numberValue(event)) + } + + +} + +const selectValue = (event: Event): string => + (event.target as HTMLInputElement | HTMLSelectElement).value +const numberValue = (event: Event): number => Number(selectValue(event)) +const checked = (event: Event): boolean => + (event.target as HTMLInputElement).checked +const subtract = (left: number, right: number): number => left - right +const eq = (left: unknown, right: unknown): boolean => left === right +const optionValue = (option: { + readonly value: number | string +}): number | string => option.value +const optionLabel = (option: { readonly label: string }): string => option.label diff --git a/examples/ember/realtime-trading/app/components/shell/diagnostics.gts b/examples/ember/realtime-trading/app/components/shell/diagnostics.gts new file mode 100644 index 0000000000..8c6f3e2600 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/diagnostics.gts @@ -0,0 +1,154 @@ +import Component from '@glimmer/component' +import type { NamedInvocationRate } from '../../benchmark/benchmark-monitor' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController } +} +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const ms = (value: number): string => `${value.toFixed(2)} ms` +const invocations = (values: ReadonlyArray): string => { + const active = values.filter((entry) => entry.callsPerSecond > 0) + return active.length + ? active + .map((entry) => `${entry.name} ${rate.format(entry.callsPerSecond)}`) + .join(' · ') + : '—' +} + +export default class Diagnostics extends Component { + get items() { + const metrics = this.args.controller.metrics + return [ + { + label: 'Mounted cells', + value: integer.format(this.args.controller.mountedCells), + testId: '', + }, + { + label: 'Live components', + value: integer.format(this.args.controller.liveComponents), + testId: '', + }, + { + label: 'Created / destroyed', + value: `${integer.format(metrics.componentsCreated)} / ${integer.format(metrics.componentsDestroyed)}`, + testId: '', + }, + { + label: 'Renderer callbacks / s', + value: rate.format(metrics.cellRendererCallsPerSecond), + testId: 'cell-render-rate', + }, + { + label: 'Component executions / s', + value: rate.format(metrics.componentRenderCallsPerSecond), + testId: 'component-render-rate', + }, + { + label: 'Executions by component / s', + value: invocations(metrics.componentRenderRates), + testId: 'component-render-breakdown', + }, + { + label: 'Callbacks by column / s', + value: invocations(metrics.cellRendererRates), + testId: 'cell-render-breakdown', + }, + { + label: 'Observed MutationRecords / s', + value: rate.format(metrics.domMutationsPerSecond), + testId: 'dom-mutation-rate', + }, + { + label: 'Worker samples / s', + value: rate.format(metrics.actualTicksPerSecond), + testId: 'actual-rate', + }, + { + label: 'Worker messages / s', + value: metrics.workerMessagesPerSecond.toFixed(1), + testId: 'message-rate', + }, + { + label: 'Changed rows / s', + value: rate.format(metrics.rowUpdatesPerSecond), + testId: 'row-update-rate', + }, + { + label: 'State snapshots / s', + value: metrics.stateApplicationsPerSecond.toFixed(1), + testId: 'state-apply-rate', + }, + { + label: 'Table DOM commits / s', + value: metrics.tableRendersPerSecond.toFixed(1), + testId: 'table-render-rate', + }, + { + label: 'P95 / max commit latency (rolling 10 s)', + value: `${ms(metrics.p95RenderMs)} / ${ms(metrics.maxRenderMs)}`, + testId: '', + }, + { + label: 'Core row model calls / s', + value: metrics.rowModelCallsPerSecond.toFixed(1), + testId: 'row-model-call-rate', + }, + { + label: 'Core row model avg / max', + value: `${ms(metrics.rowModelAverageMs)} / ${ms(metrics.rowModelMaxMs)}`, + testId: 'row-model-duration', + }, + { + label: 'Visible rows', + value: integer.format(metrics.visibleRows), + testId: 'visible-row-count', + }, + { + label: 'Worker messages since reset', + value: integer.format(metrics.workerMessages), + testId: 'worker-messages', + }, + { + label: 'Worker-coalesced updates / s', + value: rate.format(metrics.supersededUpdatesPerSecond), + testId: 'superseded-update-rate', + }, + { + label: 'Last samples / updated rows', + value: `${integer.format(metrics.lastBatchSize)} / ${integer.format(metrics.lastUpdateCount)}`, + testId: '', + }, + { + label: 'Commits > 16.7 ms since reset', + value: integer.format(metrics.slowRenders), + testId: '', + }, + { + label: 'JS heap (GC-sensitive)', + value: + metrics.heapMb === null ? 'N/A' : `${metrics.heapMb.toFixed(1)} MB`, + testId: '', + }, + ] + } + +} +const or = (left: T, right: T): T => left || right diff --git a/examples/ember/realtime-trading/app/components/shell/market-statusbar.gts b/examples/ember/realtime-trading/app/components/shell/market-statusbar.gts new file mode 100644 index 0000000000..ba6a5d31f0 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/market-statusbar.gts @@ -0,0 +1,31 @@ +import Component from '@glimmer/component' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController } +} +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) + +export default class MarketStatusbar extends Component { + get metrics() { + return this.args.controller.metrics + } + get mountedCells() { + return this.args.controller.mountedCells + } + get liveComponents() { + return this.args.controller.liveComponents + } + +} +const format = (value: number): string => integer.format(value) diff --git a/examples/ember/realtime-trading/app/components/shell/metrics-strip.gts b/examples/ember/realtime-trading/app/components/shell/metrics-strip.gts new file mode 100644 index 0000000000..70b7d6f7bd --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/metrics-strip.gts @@ -0,0 +1,59 @@ +import Component from '@glimmer/component' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController } +} +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const ms = (value: number): string => `${value.toFixed(2)} ms` + +export default class MetricsStrip extends Component { + get items() { + const metrics = this.args.controller.metrics + return [ + [ + 'FRAME RATE (EST.)', + metrics.rafCallbacksPerSecond.toFixed(1), + 'rAF callbacks/s · rolling 1 s', + 'frame-rate', + ], + [ + 'AVG COMMIT', + ms(metrics.averageRenderMs), + 'snapshot → DOM · rolling 3 s', + 'average-commit-latency', + ], + [ + 'LONG FRAMES', + this.args.controller.longAnimationFramesSupported + ? String(metrics.longAnimationFrames) + : 'N/A', + this.args.controller.longAnimationFramesSupported + ? `since reset · worst ${ms(metrics.worstLongAnimationFrameMs)}` + : 'unsupported', + 'long-frame-count', + ], + [ + 'THROUGHPUT', + `${rate.format(metrics.rowUpdatesPerSecond)} rows/s`, + `${metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows deduplicated per snapshot`, + 'throughput-rate', + ], + ] as const + } + +} + +const or = (left: T, right: T): T => left || right diff --git a/examples/ember/realtime-trading/app/components/shell/selected-instrument.gts b/examples/ember/realtime-trading/app/components/shell/selected-instrument.gts new file mode 100644 index 0000000000..55d6ce99ae --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/selected-instrument.gts @@ -0,0 +1,37 @@ +import Component from '@glimmer/component' +import type { MarketQuote } from '../../feed/market-data' +import type { MarketFeedController } from '../../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController; feed: MarketFeedController } +} + +export default class SelectedInstrument extends Component { + get quote(): MarketQuote | null { + return this.args.feed.getQuoteBySymbol( + this.args.feed.quotes, + this.args.controller.selectedSymbol, + ) + } + +} +const fixed = (value: number): string => value.toFixed(2) diff --git a/examples/ember/realtime-trading/app/components/shell/trading-shell.gts b/examples/ember/realtime-trading/app/components/shell/trading-shell.gts new file mode 100644 index 0000000000..829c484257 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/shell/trading-shell.gts @@ -0,0 +1,46 @@ +import Component from '@glimmer/component' +import { tracked } from '@glimmer/tracking' +import { on } from '@ember/modifier' +import AppHeader from './app-header.gts' +import Configurator from './configurator.gts' +import MarketStatusbar from './market-statusbar.gts' +import TradingTable from '../table/trading-table.gts' +import type { MarketFeedController } from '../../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController; feed: MarketFeedController } +} + +export default class TradingShell extends Component { + @tracked sidebarOpen = true + toggleSidebar = () => { + this.sidebarOpen = !this.sidebarOpen + } + get rootClass() { + return `trading-terminal${this.sidebarOpen ? '' : ' is-sidebar-collapsed'}` + } + +} + +const isDevelopment = import.meta.env.DEV diff --git a/examples/ember/realtime-trading/app/components/table/trading-table.gts b/examples/ember/realtime-trading/app/components/table/trading-table.gts new file mode 100644 index 0000000000..4483176749 --- /dev/null +++ b/examples/ember/realtime-trading/app/components/table/trading-table.gts @@ -0,0 +1,502 @@ +import Component from '@glimmer/component' +import { cached, tracked } from '@glimmer/tracking' +import { on } from '@ember/modifier' +import { htmlSafe } from '@ember/template' +import { modifier } from 'ember-modifier' +import { + FlexRenderCell, + FlexRenderHeader, + useTable, + type Header, + type Row, + type Table, +} from '@tanstack/ember-table' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, + type VirtualItem, +} from '@tanstack/virtual-core' +import { + createTradingColumns, + readMeasuredRows, + type RendererMode, +} from '../../table/table-config/trading-columns.gts' +import { tradingFeatures } from '../../table/trading-features' +import { + TradingGridPointerController, + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from '../../table/table-interactions' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, +} from '../../table/trading-row-virtualizer' +import { registerCleanup } from '../../utils/subscriptions' +import type Owner from '@ember/owner' +import type { MarketQuote } from '../../feed/market-data' +import type { MarketFeedController } from '../../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +interface Signature { + Args: { controller: TradingBenchmarkController; feed: MarketFeedController } +} +type TradingRow = Row +type TradingHeader = Header +interface RenderedRow { + row: TradingRow + cells: ReturnType + virtual: VirtualItem | null +} + +const captureElement = modifier( + ( + element: HTMLElement, + [capture]: [(element: HTMLElement | null) => void], + ) => { + capture(element) + return () => capture(null) + }, +) + +const markRenderCommitted = modifier( + ( + _element: HTMLElement, + [feed, quotes]: [MarketFeedController, Array], + ) => { + void quotes + queueMicrotask(() => feed.completeRender()) + }, +) + +export default class TradingTable extends Component { + @tracked virtualVersion = 0 + readonly pointer = new TradingGridPointerController() + readonly layout = { manuallyResized: false } + readonly drag = { + columnId: null as string | null, + source: null as HTMLTableCellElement | null, + target: null as HTMLTableCellElement | null, + } + scrollElement: HTMLDivElement | null = null + bodyElement: HTMLTableSectionElement | null = null + virtualizer: Virtualizer | null = null + stopVirtualizer: (() => void) | null = null + resizeObserver: ResizeObserver | null = null + mutationObserver: MutationObserver | null = null + + table: Table = useTable(this, () => ({ + features: tradingFeatures, + columns: this.columns, + data: this.data, + getRowId: (row: MarketQuote) => row.id, + columnResizeMode: 'onChange' as const, + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + })) + + constructor(owner: Owner, args: Signature['Args']) { + super(owner, args) + registerCleanup(this, () => { + this.stopVirtualizer?.() + this.resizeObserver?.disconnect() + this.mutationObserver?.disconnect() + }) + } + + @cached + get columns(): ReturnType { + return createTradingColumns( + this.args.controller, + this.args.controller.rendererMode, + ) + } + get data(): Array { + return this.args.feed.quotes + } + get selectedSymbol() { + return this.args.controller.selectedSymbol + } + + get rows(): Array { + // The table-core memo compares `table.options.data` internally. Consume the + // Ember source explicitly as well so this template getter owns the Glimmer + // tag that schedules the row-block update for an external worker message. + void this.data + return readMeasuredRows(() => this.table.getRowModel().rows) + } + get headerGroups() { + return this.table.getHeaderGroups() + } + get visibleLeafColumnCount() { + return this.table.getVisibleLeafColumns().length + } + get sourceRowCount() { + return this.args.feed.quotes.length + } + get virtualMode() { + return resolveVirtualScrollMode( + this.args.controller.requestedVirtualScrollMode, + this.args.feed.instrumentCount, + ) + } + get renderedRows(): Array { + void this.virtualVersion + const rows = this.rows + this.syncVirtualizer(rows) + const result = + this.virtualMode === 'tanstack' && this.virtualizer + ? this.virtualizer.getVirtualItems().map((item) => ({ + row: rows[item.index]!, + cells: rows[item.index]!.getVisibleCells(), + virtual: item, + })) + : rows.map((row) => ({ + row, + cells: row.getVisibleCells(), + virtual: null, + })) + queueMicrotask(() => + this.args.controller.actions.setRenderedRowCount(result.length), + ) + return result + } + get totalVirtualHeight() { + void this.virtualVersion + return ( + this.virtualizer?.getTotalSize() ?? this.rows.length * TRADING_ROW_HEIGHT + ) + } + get visibleRangeText() { + void this.virtualVersion + const range = this.virtualizer?.range + const rowCount = this.rows.length + return !range || this.virtualMode !== 'tanstack' || rowCount === 0 + ? 'Current · rows —' + : `Current · rows ${Math.min(range.startIndex, rowCount - 1)}..${Math.min(range.endIndex, rowCount - 1)}` + } + + captureScroll = (element: HTMLElement | null) => { + this.stopVirtualizer?.() + this.resizeObserver?.disconnect() + this.stopVirtualizer = null + this.resizeObserver = null + this.scrollElement = element as HTMLDivElement | null + if (!this.scrollElement) return + this.virtualizer = new Virtualizer({ + count: 0, + getScrollElement: () => this.scrollElement, + estimateSize: () => TRADING_ROW_HEIGHT, + overscan: TRADING_ROW_OVERSCAN, + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + onChange: () => { + this.virtualVersion++ + }, + }) + this.stopVirtualizer = this.virtualizer._didMount() + this.resizeObserver = new ResizeObserver(() => this.fitAvailableWidth()) + this.resizeObserver.observe(this.scrollElement) + queueMicrotask(() => { + this.fitAvailableWidth() + this.virtualVersion++ + }) + } + captureBody = (element: HTMLElement | null) => { + this.mutationObserver?.disconnect() + this.bodyElement = element as HTMLTableSectionElement | null + if (!this.bodyElement) return + this.args.controller.monitor.resetDomMutations() + this.mutationObserver = new MutationObserver((records) => + this.args.controller.monitor.recordDomMutations(records.length), + ) + this.mutationObserver.observe(this.bodyElement, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + } + + syncVirtualizer(rows: Array) { + if (!this.virtualizer) return + this.virtualizer.setOptions({ + ...this.virtualizer.options, + count: rows.length, + enabled: this.virtualMode === 'tanstack', + getItemKey: (index) => rows[index]?.id ?? index, + }) + this.virtualizer._willUpdate() + } + @cached + get columnSizeVars(): string { + void this.table.store.state.columnSizing + void this.table.store.state.columnOrder + return this.table + .getFlatHeaders() + .flatMap((header) => [ + `--header-${header.id}-size:${header.getSize()}`, + `--col-${header.column.id}-size:${header.column.getSize()}`, + ]) + .join(';') + } + get tableStyle() { + return htmlSafe( + `${this.columnSizeVars};width:${this.table.getTotalSize()}px`, + ) + } + fitAvailableWidth() { + if (!this.scrollElement || this.layout.manuallyResized) return + const width = this.table.getTotalSize() + if (this.scrollElement.clientWidth <= width + 1 || width <= 0) return + const ratio = this.scrollElement.clientWidth / width + this.table.setColumnSizing( + Object.fromEntries( + this.table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + + resizeColumn = (header: TradingHeader) => (event: Event) => { + this.layout.manuallyResized = true + header.getResizeHandler()?.(event) + } + resetColumnSize = (header: TradingHeader) => () => { + this.layout.manuallyResized = true + header.column.resetSize() + } + + onGridKeyDown = (event: KeyboardEvent) => + handleCellNavigation(this.table, event) + onBodyMouseDown = (event: MouseEvent) => + this.pointer.handleMouseDown( + this.table, + event, + this.args.controller.actions.selectSymbol, + ) + onBodyPointerOver = (event: MouseEvent) => + this.pointer.handlePointerOver(this.table, event) + onBodyClick = (event: MouseEvent) => + this.pointer.handleClick(this.table, event) + resetPointer = () => this.pointer.resetPointerCell() + clearDrag = () => { + this.drag.source?.classList.remove('is-column-dragging') + this.drag.target?.classList.remove('is-column-drop-target') + this.drag.columnId = null + this.drag.source = null + this.drag.target = null + } + dragStart = (header: TradingHeader) => (event: DragEvent) => { + this.drag.columnId = header.column.id + this.drag.source = (event.currentTarget as HTMLElement).closest('th') + this.drag.source?.classList.add('is-column-dragging') + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', header.column.id) + } + } + dragOver = (header: TradingHeader) => (event: DragEvent) => { + event.preventDefault() + this.drag.target?.classList.remove('is-column-drop-target') + this.drag.target = null + const element = (event.currentTarget as HTMLElement).closest('th') + if (this.drag.columnId !== header.column.id && element) { + element.classList.add('is-column-drop-target') + this.drag.target = element + } + } + drop = (header: TradingHeader) => (event: DragEvent) => { + event.preventDefault() + const source = + event.dataTransfer?.getData('text/plain') || this.drag.columnId + if (source) + this.table.setColumnOrder( + reorderColumnIds( + this.table.getVisibleLeafColumns().map((column) => column.id), + source, + header.column.id, + ), + ) + this.clearDrag() + } + + +} + +const isLeaf = (header: TradingHeader): boolean => + header.subHeaders.length === 0 +const canSort = (header: TradingHeader): boolean => header.column.getCanSort() +const isSorted = (header: TradingHeader): boolean => + header.column.getIsSorted() !== false +const canResize = (header: TradingHeader): boolean => + header.column.getCanResize() +const isResizing = (header: TradingHeader): boolean => + header.column.getIsResizing() +const toggleSort = (header: TradingHeader) => + header.column.getToggleSortingHandler() ?? (() => undefined) +const indicator = (header: TradingHeader): string => + sortIndicator(header.column.getIsSorted()) +const sortAria = (header: TradingHeader) => + isLeaf(header) ? sortAriaValue(header.column.getIsSorted()) : undefined +const headerStyle = (header: TradingHeader) => + htmlSafe(`width:calc(var(--header-${header.id}-size) * 1px)`) +const headerClass = (header: TradingHeader): string => + `${isLeaf(header) ? '' : 'column-group-header'} ${isLeaf(header) && !['market', 'name', 'symbol'].includes(header.column.id) ? 'numeric-header' : ''}` +const cellStyle = (columnId: string) => + htmlSafe(`width:calc(var(--col-${columnId}-size) * 1px)`) +const bodyStyle = (mode: string, height: number) => + mode === 'tanstack' ? htmlSafe(`height:${height}px`) : undefined +const rowStyle = (virtual: VirtualItem | null) => + virtual ? htmlSafe(`transform:translateY(${virtual.start}px)`) : undefined +const edgeAttr = ( + cell: { + getSelectionEdges: () => { + top: boolean + right: boolean + bottom: boolean + left: boolean + } + }, + edge: 'top' | 'right' | 'bottom' | 'left', +): string | undefined => (cell.getSelectionEdges()[edge] ? 'true' : undefined) +const rowSelected = (row: TradingRow): boolean => row.getIsSelected() +const cellSelected = ( + cell: ReturnType[number], +): boolean => cell.getIsSelected() +const cellFocused = ( + cell: ReturnType[number], +): string | undefined => (cell.getIsFocused() ? 'true' : undefined) +const cellTabIndex = ( + cell: ReturnType[number], +): number => cell.getTabIndex() +const virtualIndex = (virtual: VirtualItem | null): number | undefined => + virtual?.index +const eq = (left: unknown, right: unknown): boolean => left === right +const not = (value: boolean): boolean => !value diff --git a/examples/ember/realtime-trading/app/config.ts b/examples/ember/realtime-trading/app/config.ts new file mode 100644 index 0000000000..8b0d33268d --- /dev/null +++ b/examples/ember/realtime-trading/app/config.ts @@ -0,0 +1,17 @@ +interface Config { + environment: 'development' | 'production' + locationType: 'history' | 'hash' | 'none' | 'auto' + rootURL: string + EmberENV?: Record + APP: Record & { rootElement?: string; autoboot?: boolean } +} + +const ENV: Config = { + environment: import.meta.env.DEV ? 'development' : 'production', + rootURL: '/', + locationType: 'history', + EmberENV: {}, + APP: {}, +} + +export default ENV diff --git a/examples/ember/realtime-trading/app/feed/feed-sample-rates.ts b/examples/ember/realtime-trading/app/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..dfacf21d02 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex]! + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index]!.value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/ember/realtime-trading/app/feed/market-data.ts b/examples/ember/realtime-trading/app/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/ember/realtime-trading/app/feed/market-feed-config.ts b/examples/ember/realtime-trading/app/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/ember/realtime-trading/app/feed/market-feed-controller.ts b/examples/ember/realtime-trading/app/feed/market-feed-controller.ts new file mode 100644 index 0000000000..5dff4f0cb2 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/market-feed-controller.ts @@ -0,0 +1,215 @@ +import { tracked } from '@glimmer/tracking' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + @tracked workerReady = false + @tracked running = true + @tracked instrumentCount = initialMarketFeedConfig.instrumentCount + @tracked targetTicksPerSecond = initialMarketFeedConfig.targetSamplesPerSecond + @tracked publishIntervalMs = initialMarketFeedConfig.publishIntervalMs + @tracked updateSparklines = initialMarketFeedConfig.updateSparklines + @tracked sparklineSampleIntervalMs = + initialMarketFeedConfig.sparklineSampleIntervalMs + @tracked quotes: Array = [] + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running + this.running = running + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount = count + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond = sampleRate + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs = publishIntervalMs + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines = enabled + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs = intervalMs + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount, + running: this.running, + ticksPerSecond: this.targetTicksPerSecond, + publishIntervalMs: this.publishIntervalMs, + updateSparklines: this.updateSparklines, + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs, + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + this.quotes = quotes + this.workerReady = true + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + const quotes = applyMarketUpdates(this.quotes, data.updates) + this.quotes = quotes + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.workerReady = false + this.running = false + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady = false + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/ember/realtime-trading/app/feed/market-instruments.ts b/examples/ember/realtime-trading/app/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/ember/realtime-trading/app/feed/worker/market-feed-engine.ts b/examples/ember/realtime-trading/app/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..3e5818f4d7 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length]! + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor]! + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor]! + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/ember/realtime-trading/app/feed/worker/market-feed-protocol.ts b/examples/ember/realtime-trading/app/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/ember/realtime-trading/app/feed/worker/market-feed.worker.ts b/examples/ember/realtime-trading/app/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/ember/realtime-trading/app/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/ember/realtime-trading/app/router.ts b/examples/ember/realtime-trading/app/router.ts new file mode 100644 index 0000000000..eadbe60450 --- /dev/null +++ b/examples/ember/realtime-trading/app/router.ts @@ -0,0 +1,51 @@ +import EmbroiderRouter from '@embroider/router' +import config from '#config' + +export default class Router extends EmbroiderRouter { + location = config.locationType + rootURL = config.rootURL +} + +Router.map(function () {}) + +/** + * Caveat: + * - https://github.com/embroider-build/embroider/issues/2521 + * We don't yet have a way to do this in a nice way + * + */ +// function bundle(name: string, loader: () => Promise<{ default: unknown }>[]) { +// return { +// names: [name], +// load: async () => { +// const [template, route, controller] = await Promise.all(loader()); +// let slashName = name.replaceAll(".", "/"); +// let results: Record = {}; + +// if (template) results[`./templates/${slashName}`] = template.default; +// if (route) results[`./routes/${slashName}`] = route.default; +// if (controller) results[`./controllers/${slashName}`] = controller.default; + +// return { +// default: results, +// }; +// }, +// }; +// } + +/** + * Examples from: + * - https://github.com/NullVoxPopuli/limber/blob/67e2f54bbe224052e38f9a9e566d704411e65e86/apps/repl/app/router.ts#L35 + */ +// (window as any)._embroiderRouteBundles_ = [ +// bundle("docs", () => [import("./templates/docs.gts")]), +// bundle("docs.repl-sdk", () => [import("./templates/docs/repl-sdk.gts")]), +// bundle("docs.ember-repl", () => [import("./templates/docs/ember-repl.gts")]), +// bundle("docs.embedding", () => [import("./templates/docs/embedding.gts")]), +// bundle("docs.editor", () => [import("./templates/docs/editor.gts")]), +// bundle("docs.whatever", () => [ +// import("./the/template.gts"), +// import("./the/route.ts"), +// import("./the/controller.ts"), +// ]), +// ]; diff --git a/examples/ember/realtime-trading/app/table/table-config/quote-cells.gts b/examples/ember/realtime-trading/app/table/table-config/quote-cells.gts new file mode 100644 index 0000000000..2c2f8796c6 --- /dev/null +++ b/examples/ember/realtime-trading/app/table/table-config/quote-cells.gts @@ -0,0 +1,220 @@ +import Component from '@glimmer/component' +import { registerDestructor } from '@ember/destroyable' +import { on } from '@ember/modifier' +import type Owner from '@ember/owner' +import type { CellRenderableSignature } from '@tanstack/ember-table' +import type { MarketQuote } from '../../feed/market-data' +import type { tradingFeatures } from '../trading-features' + +export const quoteCellLifecycle = { created: 0, destroyed: 0 } + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = (names: ReadonlyArray) => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +interface PriceOptions { + selectSymbol: (symbol: string) => void +} +abstract class QuoteCell extends Component { + constructor(owner: Owner, args: object, name: QuoteComponentName) { + super(owner, args) + quoteCellLifecycle.created++ + registerDestructor(this, () => { + quoteCellLifecycle.destroyed++ + }) + } +} + +export class PriceCell extends QuoteCell< + CellRenderableSignature< + typeof tradingFeatures, + MarketQuote, + number, + PriceOptions + > +> { + constructor(owner: Owner, args: object) { + super(owner, args, 'PriceCell') + } + get quote() { + return this.args.ctx.row.original + } + select = () => { + this.args.options?.selectSymbol(this.quote.symbol) + } + +} + +abstract class MoveCellBase extends QuoteCell< + CellRenderableSignature +> { + get move() { + return ( + this.args.ctx.row.original.price - + this.args.ctx.row.original.previousClose + ) + } + get positive() { + return this.move >= 0 + } +} + +export class StableMoveCell extends MoveCellBase { + constructor(owner: Owner, args: object) { + super(owner, args, 'StableMoveCell') + } + +} + +export class UpMoveCell extends MoveCellBase { + constructor(owner: Owner, args: object) { + super(owner, args, 'UpMoveCell') + } + +} + +export class DownMoveCell extends MoveCellBase { + constructor(owner: Owner, args: object) { + super(owner, args, 'DownMoveCell') + } + +} + +export class PercentChangeCell extends QuoteCell< + CellRenderableSignature +> { + constructor(owner: Owner, args: object) { + super(owner, args, 'PercentChangeCell') + } + get value() { + const quote = this.args.ctx.row.original + return quote.previousClose === 0 + ? 0 + : ((quote.price - quote.previousClose) / quote.previousClose) * 100 + } + +} + +export class SparklineCell extends QuoteCell< + CellRenderableSignature +> { + constructor(owner: Owner, args: object) { + super(owner, args, 'SparklineCell') + } + get values() { + return this.args.ctx.row.original.history + } + get rising() { + return (this.values.at(-1) ?? 0) >= (this.values[0] ?? 0) + } + get points() { + const first = this.values[0] ?? 0 + const range = this.values.reduce( + (result, value) => ({ + min: Math.min(result.min, value), + max: Math.max(result.max, value), + }), + { min: first, max: first }, + ) + const scale = range.max - range.min || 1 + const denominator = Math.max(1, this.values.length - 1) + return this.values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - range.min) / scale) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + } + +} + +const fixed = (value: number): string => value.toFixed(2) +const signed = (value: number): string => + `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +const percent = (value: number): string => + `${value >= 0 ? '+' : ''}${value.toFixed(2)}%` +const nonNegative = (value: number): boolean => value >= 0 +const isPositive = (quote: MarketQuote): boolean => + quote.price - quote.previousClose >= 0 +const recordComponent = (name: QuoteComponentName): string => { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[name]++ + return '' +} diff --git a/examples/ember/realtime-trading/app/table/table-config/trading-columns.gts b/examples/ember/realtime-trading/app/table/table-config/trading-columns.gts new file mode 100644 index 0000000000..d4ca99a40f --- /dev/null +++ b/examples/ember/realtime-trading/app/table/table-config/trading-columns.gts @@ -0,0 +1,237 @@ +import { flexRenderComponent } from '@tanstack/ember-table' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells.gts' +import type { ColumnDef } from '@tanstack/ember-table' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' +import type { tradingFeatures } from '../trading-features' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +const compact = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function createTradingColumns( + controller: TradingBenchmarkController, + rendererMode: RendererMode, +): Array> { + return [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: () => + recordCellRender( + 'Last', + flexRenderComponent(PriceCell, { + selectSymbol: controller.actions.selectSymbol, + }), + ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: getDayChange, + cell: ({ row }) => + recordCellRender( + 'Change', + rendererMode === 'stable' + ? flexRenderComponent(StableMoveCell) + : getDayChange(row.original) >= 0 + ? flexRenderComponent(UpMoveCell) + : flexRenderComponent(DownMoveCell), + ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: getDayChangePercent, + cell: () => + recordCellRender( + 'ChangePercent', + flexRenderComponent(PercentChangeCell), + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => + recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender('BidVolume', compact.format(row.original.bidSize)), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => + recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender('AskVolume', compact.format(row.original.askSize)), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => + recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: () => + recordCellRender('Intraday', flexRenderComponent(SparklineCell)), + }, + ], + }, + ] +} + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 !== 0) return rows + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 20_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + } catch { + /* optional User Timing detail */ + } + return rows +} + +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/ember/realtime-trading/app/table/table-interactions.ts b/examples/ember/realtime-trading/app/table/table-interactions.ts new file mode 100644 index 0000000000..a32e9d5963 --- /dev/null +++ b/examples/ember/realtime-trading/app/table/table-interactions.ts @@ -0,0 +1,189 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + if (!row) return null + const cell = row.getAllCellsByColumnId()[columnId] + if (!cell) return null + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/ember/realtime-trading/app/table/trading-features.ts b/examples/ember/realtime-trading/app/table/trading-features.ts new file mode 100644 index 0000000000..7d0a780dda --- /dev/null +++ b/examples/ember/realtime-trading/app/table/trading-features.ts @@ -0,0 +1,16 @@ +import { + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, +} from '@tanstack/ember-table' + +export const tradingFeatures = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) diff --git a/examples/ember/realtime-trading/app/table/trading-row-virtualizer.ts b/examples/ember/realtime-trading/app/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/ember/realtime-trading/app/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/ember/realtime-trading/app/table/trading-table.ts b/examples/ember/realtime-trading/app/table/trading-table.ts new file mode 100644 index 0000000000..033929e969 --- /dev/null +++ b/examples/ember/realtime-trading/app/table/trading-table.ts @@ -0,0 +1,9 @@ +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-columns.gts' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns.gts' +export type { VirtualScrollMode } from './trading-row-virtualizer' diff --git a/examples/ember/realtime-trading/app/templates/application.gts b/examples/ember/realtime-trading/app/templates/application.gts new file mode 100644 index 0000000000..695fb27818 --- /dev/null +++ b/examples/ember/realtime-trading/app/templates/application.gts @@ -0,0 +1,25 @@ +import Component from '@glimmer/component' +import { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' +import { MarketFeedController } from '../feed/market-feed-controller' +import TradingShell from '../components/shell/trading-shell.gts' +import { registerCleanup } from '../utils/subscriptions' +import type Owner from '@ember/owner' + +export default class Application extends Component { + readonly feed = new MarketFeedController() + readonly controller = new TradingBenchmarkController(this.feed) + + constructor(owner: Owner, args: object) { + super(owner, args) + const stopFeed = this.feed.start() + const stopBenchmark = this.controller.start() + registerCleanup(this, () => { + stopBenchmark() + stopFeed() + }) + } + + +} diff --git a/examples/ember/realtime-trading/app/utils/subscriptions.ts b/examples/ember/realtime-trading/app/utils/subscriptions.ts new file mode 100644 index 0000000000..7d6ea1eaeb --- /dev/null +++ b/examples/ember/realtime-trading/app/utils/subscriptions.ts @@ -0,0 +1,5 @@ +import { registerDestructor } from '@ember/destroyable' + +export function registerCleanup(owner: object, cleanup: () => void): void { + registerDestructor(owner, cleanup) +} diff --git a/examples/ember/realtime-trading/babel.config.js b/examples/ember/realtime-trading/babel.config.js new file mode 100644 index 0000000000..10915e4299 --- /dev/null +++ b/examples/ember/realtime-trading/babel.config.js @@ -0,0 +1,49 @@ +import { buildMacros } from '@embroider/macros/babel' + +const macros = buildMacros({ + configure(config) { + if (process.env.EMBER_ENV === 'test') { + config.enableRuntimeMode() + } + }, +}) + +export default { + plugins: [ + [ + '@babel/plugin-transform-typescript', + { + allExtensions: true, + onlyRemoveTypeImports: true, + allowDeclareFields: true, + }, + ], + [ + 'babel-plugin-ember-template-compilation', + { + transforms: [...macros.templateMacros], + }, + ], + [ + 'module:decorator-transforms', + { + runtime: { + import: import.meta.resolve('decorator-transforms/runtime-esm'), + }, + }, + ], + [ + '@babel/plugin-transform-runtime', + { + absoluteRuntime: import.meta.dirname, + useESModules: true, + regenerator: false, + }, + ], + ...macros.babelMacros, + ], + + generatorOpts: { + compact: false, + }, +} diff --git a/examples/ember/realtime-trading/index.html b/examples/ember/realtime-trading/index.html new file mode 100644 index 0000000000..b9dbf7e836 --- /dev/null +++ b/examples/ember/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + Minimal App + + + + + + + diff --git a/examples/ember/realtime-trading/package.json b/examples/ember/realtime-trading/package.json new file mode 100644 index 0000000000..68eadf1896 --- /dev/null +++ b/examples/ember/realtime-trading/package.json @@ -0,0 +1,59 @@ +{ + "name": "tanstack-ember-table-example-realtime-trading", + "private": true, + "type": "module", + "imports": { + "#app/*": "./app/*", + "#config": "./app/config.ts", + "#components/*": "./app/components/*", + "#services/*": "./app/services/*", + "#test-helpers/*": "./tests/helpers/*", + "#utils/*": "./app/utils/*" + }, + "exports": { + "./tests/*": "./tests/*", + "./*": "./app/*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "start": "vite", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts", + "test:types": "ember-tsc --noEmit" + }, + "dependencies": { + "@embroider/macros": "1.20.5", + "@embroider/router": "3.0.6", + "@glimmer/component": "2.1.1", + "@tanstack/ember-table": "^9.1.2", + "@tanstack/store": "^0.11.0", + "@tanstack/virtual-core": "^3.13.36", + "decorator-transforms": "2.4.0", + "ember-modifier": "^4.3.0", + "ember-source": "7.1.0", + "ember-strict-application-resolver": "0.1.1" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/plugin-transform-runtime": "7.29.7", + "@babel/plugin-transform-typescript": "7.29.7", + "@babel/runtime": "7.29.7", + "@ember/app-tsconfig": "2.0.0", + "@embroider/core": "4.6.2", + "@glint/ember-tsc": "1.8.14", + "@glint/template": "1.7.10", + "@glint/tsserver-plugin": "2.5.20", + "@nullvoxpopuli/ember-vite": "1.1.0", + "@rollup/plugin-babel": "7.1.0", + "babel-plugin-ember-template-compilation": "4.0.0", + "typescript": "6.0.3", + "vite": "^8.2.0" + }, + "engines": { + "node": ">= 24" + }, + "ember": { + "edition": "octane" + } +} diff --git a/examples/ember/realtime-trading/tests/e2e/smoke.spec.ts b/examples/ember/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..6a78ee84b3 --- /dev/null +++ b/examples/ember/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Ember realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/ember/realtime-trading/tsconfig.json b/examples/ember/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..c4f982dc22 --- /dev/null +++ b/examples/ember/realtime-trading/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@ember/app-tsconfig", + "compilerOptions": { + "allowJs": true, + "lib": ["ES2025", "DOM", "DOM.Iterable"], + "types": [ + "ember-source/types", + "@embroider/core/virtual", + "vite/client", + "@glint/ember-tsc/types" + ] + } +} diff --git a/examples/ember/realtime-trading/vite.config.mjs b/examples/ember/realtime-trading/vite.config.mjs new file mode 100644 index 0000000000..d09660d2f1 --- /dev/null +++ b/examples/ember/realtime-trading/vite.config.mjs @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { ember } from '@nullvoxpopuli/ember-vite' + +export default defineConfig({ + server: { + port: 7785, + allowedHosts: true, + }, + plugins: [ember()], +}) diff --git a/examples/lit/realtime-trading/.gitignore b/examples/lit/realtime-trading/.gitignore new file mode 100644 index 0000000000..1502ec233f --- /dev/null +++ b/examples/lit/realtime-trading/.gitignore @@ -0,0 +1,3 @@ +dist +node_modules + diff --git a/examples/lit/realtime-trading/README.md b/examples/lit/realtime-trading/README.md new file mode 100644 index 0000000000..f43f3728c3 --- /dev/null +++ b/examples/lit/realtime-trading/README.md @@ -0,0 +1,147 @@ +# Lit realtime trading benchmark + +This standalone example exercises the current TanStack Lit Table adapter with +a high-frequency worker feed, immutable snapshots, interactive columns, custom +elements for quote cells, Lit Virtual, and browser diagnostics. It is a +repeatable rendering workload, not an exchange or network benchmark. + +## Run and verify + +```bash +pnpm --dir examples/lit/realtime-trading dev +``` + +Open `http://localhost:7783`. + +```bash +pnpm --dir examples/lit/realtime-trading test:types +pnpm --dir examples/lit/realtime-trading lint +pnpm --dir examples/lit/realtime-trading build +pnpm --dir examples/lit/realtime-trading test:e2e +``` + +Use the production build for measurements. + +## Structure and ownership + +| Path | Responsibility | +| --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `src/feed/` | Market types, instruments, configuration, immutable updates, and direct TanStack atom feed controller. | +| `src/feed/worker/` | Protocol, deterministic engine, and module worker. | +| `src/benchmark/` | Browser monitor, benchmark controller, table timing, and row-model diagnostics. | +| `src/shell/` | Custom elements for header, metrics, controls, diagnostics, selected instrument, status, and layout. | +| `src/shell/controller-element.ts` | Base element that subscribes to controller sources and releases subscriptions on disconnect. | +| `src/table/table-config/` | Grouped columns and custom quote cell elements. | +| `src/table/` | Lit Table element, delegated interactions, column layout, and Lit Virtual integration. | +| `src/main.ts` | Root custom element that creates, starts, stops, and passes the controllers. | + +The feed controller is independent from the benchmark controller. The root +passes stable controller objects; each shell/table custom element observes the +specific atom/store sources it consumes. Quotes and every feed control/status +value are independent direct atoms; the table observes only `quotes` and +`instrumentCount`. `ControllerElement` converts those +notifications into `requestUpdate()` and guarantees unsubscribe on disconnect, +avoiding one broad application-level subscription. + +## Feed and worker pipeline + +Defaults are 100 instruments, 10K generated samples/s, 20 ms delivery, enabled +intraday charts, and 16 ms chart sampling. + +Mutable market state stays inside the worker. A deterministic 16 ms budget loop +generates samples, a row-indexed `Map` coalesces repeated instrument changes, +and an independent timer publishes the latest unique rows. The main thread +creates a new outer array and only new changed rows; untouched rows and +unsampled histories retain identity. Session IDs reject obsolete batches after +reset or instrument-count changes. + +- **Synthetic quote workload** is worker-generated samples/s, not custom-element + updates or messages. +- **Worker delivery interval** controls coalesced `postMessage` cadence; 20 ms + targets about 50 messages/s. +- **Row updates** is the count of unique immutable rows applied. +- **Message samples** is generated work represented by the latest batch. + +Intraday sampling is independent. The 25K burst deliberately publishes one +heavy batch. Worker messaging resembles an upstream stream without including +network latency. + +## Lit table architecture + +The 14 leaf columns are grouped into Instrument, Price & Change, Order Book, +Session, and Chart. The grid supports sorting/filtering, on-change resizing, +double-click reset, drag column ordering, CSS hover, row selection, drag cell +ranges, keyboard navigation, and Price/Move/Percent/Sparkline custom elements. + +Stable instrument IDs define row identity. One body-level interaction +controller resolves cells from `event.composedPath()` and data attributes, +avoiding handlers on every cell. Column widths are CSS variables written only +when sizing/order changes; a `ResizeObserver` performs initial fitting until a +manual resize. The A/B move option is intentionally a custom-element lifecycle +stress path. + +## Virtualization + +- Below 200 rows, automatic mode uses Full DOM, with Virtual still selectable. +- From 200 through 1,499 rows, automatic mode uses TanStack Virtual, with Full + DOM still selectable. +- At 1,500 rows or more, Virtual is forced and its control is disabled. + +Lit Virtual uses a 32 px estimate, 10-row overscan, stable row IDs, transformed +rows, and a spacer body. The footer derives the visible interval from the +virtualizer. Both modes apply `content-visibility: auto`; Full DOM still creates +all elements even when the browser skips some offscreen layout/paint. + +## Performance decisions + +- worker-side generation and coalescing; +- structural sharing for unchanged rows/history arrays; +- source-specific atom/store subscriptions in custom elements; +- stable keyed row/virtual identity; +- lifecycle-safe controller element subscriptions; +- delegated pointer input and CSS hover; +- CSS variables for width propagation; +- independently configurable chart/component stress; +- virtual mounting at larger sizes; +- metrics published below feed frequency. + +A new outer array is required by the immutable contract. Stable row references +help rendering, but sorting/filtering may still recompute the row model when +data changes. + +## Diagnostics and interpretation + +The sidebar keeps four cross-framework health signals prominent: estimated +frame callbacks, average snapshot-to-DOM-commit latency, cumulative long +animation frames, and changed-row/snapshot throughput. The remaining counters +stay in Diagnostics so they do not look like equally important scores. + +`AVG COMMIT` is not the duration of `render()` and it does not include the +browser's later layout or paint. It starts when a new immutable snapshot is +applied and ends in a guarded microtask queued from `updated()`. That extra +microtask lets nested quote-cell custom elements finish their Lit updates +before the sample closes. The average uses a rolling 3-second window; diagnostic +p95/max use 10 seconds. The frame-rate estimate counts standard +`requestAnimationFrame` callbacks over one second. It is refresh-rate dependent +and is not a GPU/compositor FPS measurement. + +Callback counts and DOM mutations describe different layers. +`Observed MutationRecords/s` is the number delivered by a `MutationObserver` +on the table body, not the number of browser DOM operations. The observer tracks +text, child-list, class, and style changes; it excludes selection data +attributes to reduce noise. Registering an observer still adds work at very high +mutation rates, so confirm close results with a Performance recording without +treating this counter as a score. Heap is Chrome-only, current and GC-sensitive; +growth is not a leak without post-GC retention. React Scan is not part of these +shared measurements. User Timing commit and row-model entries are sampled once +every 20 candidates; the numeric counters remain exact, while the Performance +timeline avoids one retained entry per hot-path execution. + +## Standalone policy + +All instruments, feed, worker, benchmark, shell, styles, and table code live in +this folder intentionally. It runs independently and can be copied to +StackBlitz, so shared implementation and README text are duplicated by design. + +The workspace resolves the pinned `@tanstack/lit-table` dependency to the local +adapter package while keeping the manifest release-like. diff --git a/examples/lit/realtime-trading/index.html b/examples/lit/realtime-trading/index.html new file mode 100644 index 0000000000..25afac7a22 --- /dev/null +++ b/examples/lit/realtime-trading/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Lit Table · Realtime Trading + + + + + + diff --git a/examples/lit/realtime-trading/package.json b/examples/lit/realtime-trading/package.json new file mode 100644 index 0000000000..0faf16cf38 --- /dev/null +++ b/examples/lit/realtime-trading/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-lit-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/lit-table": "9.1.2", + "@tanstack/lit-virtual": "^3.13.36", + "@tanstack/store": "^0.11.0", + "lit": "^3.3.3" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/lit/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/lit/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..1bf9cdbc8c --- /dev/null +++ b/examples/lit/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,436 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCandidateCount: 0 } +const userTimingSamplingInterval = 20 + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCandidateCount++ + if (userTiming.measureCandidateCount % userTimingSamplingInterval !== 0) + return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + pendingMutationStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markCommitPending(): void { + this.#runtime.pendingMutationStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingMutationStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingMutationStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingMutationStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingMutationStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingMutationStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/lit/realtime-trading/src/benchmark/table-benchmark.ts b/examples/lit/realtime-trading/src/benchmark/table-benchmark.ts new file mode 100644 index 0000000000..346a6cdc0c --- /dev/null +++ b/examples/lit/realtime-trading/src/benchmark/table-benchmark.ts @@ -0,0 +1,23 @@ +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function startTableBenchmark( + controller: TradingBenchmarkController, +): () => void { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) return () => undefined + + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() +} diff --git a/examples/lit/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/lit/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..12e65e30ea --- /dev/null +++ b/examples/lit/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/lit/realtime-trading/src/feed/feed-sample-rates.ts b/examples/lit/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/lit/realtime-trading/src/feed/market-data.ts b/examples/lit/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/lit/realtime-trading/src/feed/market-feed-config.ts b/examples/lit/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/lit/realtime-trading/src/feed/market-feed-controller.ts b/examples/lit/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..40f523562d --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/lit/realtime-trading/src/feed/market-instruments.ts b/examples/lit/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/lit/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/lit/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/lit/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/lit/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/lit/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/lit/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/lit/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/lit/realtime-trading/src/index.css b/examples/lit/realtime-trading/src/index.css new file mode 100644 index 0000000000..20ff776c06 --- /dev/null +++ b/examples/lit/realtime-trading/src/index.css @@ -0,0 +1,1062 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +trading-metrics-strip { + display: block; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/lit/realtime-trading/src/main.ts b/examples/lit/realtime-trading/src/main.ts new file mode 100644 index 0000000000..b0c36824e4 --- /dev/null +++ b/examples/lit/realtime-trading/src/main.ts @@ -0,0 +1,35 @@ +import { LitElement, html } from 'lit' +import { customElement } from 'lit/decorators.js' +import { TradingBenchmarkController } from './benchmark/trading-benchmark-controller' +import { MarketFeedController } from './feed/market-feed-controller' +import './shell/TradingShell' +import './index.css' + +@customElement('realtime-trading-app') +export class RealtimeTradingApp extends LitElement { + readonly #feed = new MarketFeedController() + readonly #benchmark = new TradingBenchmarkController(this.#feed) + readonly #stop = { + feed: null as (() => void) | null, + benchmark: null as (() => void) | null, + } + protected createRenderRoot() { + return this + } + connectedCallback() { + super.connectedCallback() + this.#stop.feed = this.#feed.start() + this.#stop.benchmark = this.#benchmark.start() + } + disconnectedCallback() { + this.#stop.benchmark?.() + this.#stop.feed?.() + super.disconnectedCallback() + } + protected render() { + return html`` + } +} diff --git a/examples/lit/realtime-trading/src/shell/AppHeader.ts b/examples/lit/realtime-trading/src/shell/AppHeader.ts new file mode 100644 index 0000000000..4b349bab9b --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/AppHeader.ts @@ -0,0 +1,42 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { ControllerElement } from './controller-element' +import type { MarketFeedController } from '../feed/market-feed-controller' + +@customElement('trading-app-header') +export class AppHeader extends ControllerElement { + @property({ attribute: false }) feed!: MarketFeedController + @property({ type: Boolean }) sidebarOpen = true + @property({ attribute: false }) toggleSidebar: () => void = () => undefined + protected firstUpdated() { + this.observe(this.feed.workerReady) + this.observe(this.feed.running) + } + protected render() { + const workerReady = this.feed.workerReady.get() + const running = this.feed.running.get() + return html`
+
MARKET MONITOR
+
+ ${!workerReady ? 'FEED CONNECTING' : running ? 'FEED LIVE' : 'FEED PAUSED'} +
+
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/Configurator.ts b/examples/lit/realtime-trading/src/shell/Configurator.ts new file mode 100644 index 0000000000..2275e2d605 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/Configurator.ts @@ -0,0 +1,215 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { configuratorOptions } from './configurator-options' +import { ControllerElement } from './controller-element' +import './Diagnostics' +import './MetricsStrip' +import './SelectedInstrument' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const value = (event: Event) => + (event.target as HTMLInputElement | HTMLSelectElement).value +const number = (event: Event) => Number(value(event)) +const checked = (event: Event) => (event.target as HTMLInputElement).checked +const options = ( + items: ReadonlyArray<{ + readonly label: string + readonly value: number | string + }>, + selectedValue: number | string, +) => + repeat( + items, + (item) => item.value, + (item) => + html``, + ) + +@customElement('trading-configurator') +export class Configurator extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + @property({ attribute: false }) feed!: MarketFeedController + protected firstUpdated() { + this.observe(this.controller.store) + this.observe(this.controller.renderAtoms.rendererMode) + this.observe(this.feed.running) + this.observe(this.feed.instrumentCount) + this.observe(this.feed.targetTicksPerSecond) + this.observe(this.feed.publishIntervalMs) + this.observe(this.feed.updateSparklines) + this.observe(this.feed.sparklineSampleIntervalMs) + } + protected render() { + const feedState = { + running: this.feed.running.get(), + instrumentCount: this.feed.instrumentCount.get(), + targetTicksPerSecond: this.feed.targetTicksPerSecond.get(), + publishIntervalMs: this.feed.publishIntervalMs.get(), + updateSparklines: this.feed.updateSparklines.get(), + sparklineSampleIntervalMs: this.feed.sparklineSampleIntervalMs.get(), + } + const benchmark = this.controller.store.get() + const forced = feedState.instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT + const virtualMode = resolveVirtualScrollMode( + benchmark.requestedVirtualScrollMode, + feedState.instrumentCount, + ) + return html`` + } +} diff --git a/examples/lit/realtime-trading/src/shell/Diagnostics.ts b/examples/lit/realtime-trading/src/shell/Diagnostics.ts new file mode 100644 index 0000000000..2d78db9cd8 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/Diagnostics.ts @@ -0,0 +1,153 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { ControllerElement } from './controller-element' +import type { NamedInvocationRate } from '../benchmark/benchmark-monitor' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const ms = (value: number) => `${value.toFixed(2)} ms` +const invocations = (values: ReadonlyArray) => { + const active = values.filter((entry) => entry.callsPerSecond > 0) + return active.length + ? active + .map((entry) => `${entry.name} ${rate.format(entry.callsPerSecond)}`) + .join(' · ') + : '—' +} +@customElement('trading-diagnostics') +export class Diagnostics extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + protected firstUpdated() { + this.observe(this.controller.store) + } + protected render() { + const state = this.controller.store.get() + const metrics = state.metrics + const items = [ + [ + 'Worker-generated samples / s', + rate.format(metrics.actualTicksPerSecond), + 'actual-rate', + ], + [ + 'Changed rows / s', + rate.format(metrics.rowUpdatesPerSecond), + 'row-update-rate', + ], + [ + 'Worker messages / s', + metrics.workerMessagesPerSecond.toFixed(1), + 'message-rate', + ], + [ + 'Snapshots applied / s', + metrics.stateApplicationsPerSecond.toFixed(1), + 'state-apply-rate', + ], + [ + 'DOM commits / s', + metrics.tableCommitsPerSecond.toFixed(1), + 'table-render-rate', + ], + [ + 'Commit latency p95 / max (10 s)', + `${ms(metrics.p95CommitLatencyMs)} / ${ms(metrics.maxCommitLatencyMs)}`, + '', + ], + ['Mounted cells', integer.format(state.mountedCells), ''], + ['Live components', integer.format(state.liveComponents), ''], + [ + 'Created / destroyed', + `${integer.format(metrics.componentsCreated)} / ${integer.format(metrics.componentsDestroyed)}`, + '', + ], + [ + 'Renderer callbacks / s', + rate.format(metrics.cellRendererCallsPerSecond), + 'cell-render-rate', + ], + [ + 'Component executions / s', + rate.format(metrics.componentRenderCallsPerSecond), + 'component-render-rate', + ], + [ + 'Executions by component / s', + invocations(metrics.componentRenderRates), + 'component-render-breakdown', + ], + [ + 'Callbacks by column / s', + invocations(metrics.cellRendererRates), + 'cell-render-breakdown', + ], + [ + 'Observed MutationRecords / s', + rate.format(metrics.domMutationsPerSecond), + 'dom-mutation-rate', + ], + [ + 'Core row model calls / s', + metrics.rowModelCallsPerSecond.toFixed(1), + 'row-model-call-rate', + ], + [ + 'Core row model avg / max', + `${ms(metrics.rowModelAverageMs)} / ${ms(metrics.rowModelMaxMs)}`, + 'row-model-duration', + ], + [ + 'Visible rows', + integer.format(metrics.visibleRows), + 'visible-row-count', + ], + [ + 'Worker messages', + integer.format(metrics.workerMessages), + 'worker-messages', + ], + [ + 'Worker-coalesced updates / s', + rate.format(metrics.supersededUpdatesPerSecond), + 'superseded-update-rate', + ], + [ + 'Last samples / updated rows', + `${integer.format(metrics.lastBatchSize)} / ${integer.format(metrics.lastUpdateCount)}`, + '', + ], + [ + 'Commits > 16.7 ms (since reset)', + integer.format(metrics.slowCommits), + '', + ], + [ + 'JS heap (current, GC-sensitive)', + metrics.heapMb === null ? 'N/A' : `${metrics.heapMb.toFixed(1)} MB`, + '', + ], + ] as const + return html`
+

DIAGNOSTICS

+
+ ${repeat( + items, + (item) => item[0], + (item) => + html`
+
${item[0]}
+
${item[1]}
+
`, + )} +
+
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/MarketStatusbar.ts b/examples/lit/realtime-trading/src/shell/MarketStatusbar.ts new file mode 100644 index 0000000000..8c6a569c09 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/MarketStatusbar.ts @@ -0,0 +1,29 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { ControllerElement } from './controller-element' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +@customElement('trading-market-statusbar') +export class MarketStatusbar extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + protected firstUpdated() { + this.observe(this.controller.store) + } + protected render() { + const state = this.controller.store.get() + return html`
+ MESSAGE SAMPLES + ${integer.format(state.metrics.lastBatchSize)}CHANGED ROWS + ${integer.format(state.metrics.lastUpdateCount)}HOSTS ${integer.format(state.mountedCells)}COMPONENTS + ${integer.format(state.liveComponents)} +
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/MetricsStrip.ts b/examples/lit/realtime-trading/src/shell/MetricsStrip.ts new file mode 100644 index 0000000000..6dcb4bb849 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/MetricsStrip.ts @@ -0,0 +1,65 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { ControllerElement } from './controller-element' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const rate = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +const ms = (value: number) => `${value.toFixed(2)} ms` +@customElement('trading-metrics-strip') +export class MetricsStrip extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + protected firstUpdated() { + this.observe(this.controller.store) + } + protected render() { + const state = this.controller.store.get() + const metrics = state.metrics + const items = [ + [ + 'FRAME RATE (EST.)', + metrics.rafCallbacksPerSecond.toFixed(1), + 'rAF callbacks/s · rolling 1 s', + 'frame-rate', + ], + [ + 'AVG COMMIT', + ms(metrics.averageCommitLatencyMs), + 'snapshot → DOM · rolling 3 s', + 'average-commit-latency', + ], + [ + 'LONG FRAMES', + state.longAnimationFramesSupported + ? String(metrics.longAnimationFrames) + : 'N/A', + state.longAnimationFramesSupported + ? `since reset · worst ${ms(metrics.worstLongAnimationFrameMs)}` + : 'unsupported', + 'long-frame-count', + ], + [ + 'THROUGHPUT', + `${rate.format(metrics.rowUpdatesPerSecond)} rows/s`, + `${metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows deduplicated per snapshot`, + 'throughput-rate', + ], + ] as const + return html`
+

LIVE HEALTH

+ ${repeat( + items, + (item) => item[0], + (item) => + html`
+ ${item[0]}${item[1]}${item[2]} +
`, + )} +
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/SelectedInstrument.ts b/examples/lit/realtime-trading/src/shell/SelectedInstrument.ts new file mode 100644 index 0000000000..4676a16fe5 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/SelectedInstrument.ts @@ -0,0 +1,50 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { ControllerElement } from './controller-element' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +@customElement('trading-selected-instrument') +export class SelectedInstrument extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + @property({ attribute: false }) feed!: MarketFeedController + protected firstUpdated() { + this.observe(this.controller.renderAtoms.selectedSymbol) + this.observe(this.feed.quotes) + } + protected render() { + const quote = this.feed.getQuoteBySymbol( + this.feed.quotes.get(), + this.controller.renderAtoms.selectedSymbol.get(), + ) + return html`
+

SELECTED INSTRUMENT

+ ${ + quote + ? html`
+
+ ${quote.symbol}${quote.company} +
+ ${quote.venue} +
+
+
+
Last
+
${quote.price.toFixed(2)}
+
+
+
Bid / ask
+
${quote.bid.toFixed(2)} / ${quote.ask.toFixed(2)}
+
+
` + : html`

+ Click or begin a cell selection in any row to inspect its + instrument. +

` + } +
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/TradingShell.ts b/examples/lit/realtime-trading/src/shell/TradingShell.ts new file mode 100644 index 0000000000..dc34079d74 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/TradingShell.ts @@ -0,0 +1,44 @@ +import { html } from 'lit' +import { customElement, property, state } from 'lit/decorators.js' +import { ControllerElement } from './controller-element' +import './AppHeader' +import './Configurator' +import './MarketStatusbar' +import '../table/TradingTable' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +@customElement('trading-shell') +export class TradingShell extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + @property({ attribute: false }) feed!: MarketFeedController + @state() private sidebarOpen = true + protected render() { + return html`
+
+ { + this.sidebarOpen = !this.sidebarOpen + }} + >${import.meta.env.DEV ? html`` : null} +
+
+ +
+ + +
` + } +} diff --git a/examples/lit/realtime-trading/src/shell/configurator-options.ts b/examples/lit/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/lit/realtime-trading/src/shell/controller-element.ts b/examples/lit/realtime-trading/src/shell/controller-element.ts new file mode 100644 index 0000000000..01ca122f3c --- /dev/null +++ b/examples/lit/realtime-trading/src/shell/controller-element.ts @@ -0,0 +1,19 @@ +import { LitElement } from 'lit' +import type { Subscription } from '@tanstack/store' + +export abstract class ControllerElement extends LitElement { + readonly #subscriptions: Array = [] + protected createRenderRoot() { + return this + } + protected observe(source: { + subscribe: (listener: () => void) => Subscription + }): void { + this.#subscriptions.push(source.subscribe(() => this.requestUpdate())) + } + disconnectedCallback() { + for (const subscription of this.#subscriptions) subscription.unsubscribe() + this.#subscriptions.length = 0 + super.disconnectedCallback() + } +} diff --git a/examples/lit/realtime-trading/src/table/TradingTable.ts b/examples/lit/realtime-trading/src/table/TradingTable.ts new file mode 100644 index 0000000000..01fbbe65d9 --- /dev/null +++ b/examples/lit/realtime-trading/src/table/TradingTable.ts @@ -0,0 +1,407 @@ +import { html } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { repeat } from 'lit/directives/repeat.js' +import { createRef, ref } from 'lit/directives/ref.js' +import { VirtualizerController } from '@tanstack/lit-virtual' +import { + FlexRender, + TableController, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, +} from '@tanstack/lit-table' +import { startTableBenchmark } from '../benchmark/table-benchmark' +import { ControllerElement } from '../shell/controller-element' +import { + createTradingColumns, + readMeasuredRows, +} from './table-config/trading-columns' +import { + TradingGridPointerController, + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from './table-interactions' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, +} from './trading-row-virtualizer' +import './table-config/quote-cells' +import type { Ref } from 'lit/directives/ref.js' +import type { LitTable } from '@tanstack/lit-table' +import type { MarketQuote } from '../feed/market-data' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) +interface SelectedTableState { + sorting: unknown + columnFilters: unknown + columnOrder: unknown + rowSelection: unknown + cellSelection: unknown +} +type TradingTableInstance = LitTable< + typeof features, + MarketQuote, + SelectedTableState +> + +@customElement('trading-data-table') +export class TradingTable extends ControllerElement { + @property({ attribute: false }) controller!: TradingBenchmarkController + @property({ attribute: false }) feed!: MarketFeedController + readonly #tableController = new TableController( + this, + ) + readonly #scrollRef: Ref = createRef() + readonly #tableRef: Ref = createRef() + readonly #virtualizer = new VirtualizerController< + HTMLDivElement, + HTMLTableRowElement + >(this, { + count: 0, + getScrollElement: () => this.#scrollRef.value ?? null, + estimateSize: () => TRADING_ROW_HEIGHT, + overscan: TRADING_ROW_OVERSCAN, + }) + readonly #pointer = new TradingGridPointerController() + readonly #drag = { + columnId: null as string | null, + source: null as HTMLTableCellElement | null, + target: null as HTMLTableCellElement | null, + } + readonly #layout = { manuallyResized: false } + #table?: TradingTableInstance + #columns?: ReturnType> + #cleanup: Array<() => void> = [] + #domCommitScheduled = false + + protected firstUpdated() { + this.observe(this.feed.quotes) + this.observe(this.feed.instrumentCount) + this.observe(this.controller.store) + this.observe(this.controller.renderAtoms.rendererMode) + this.observe(this.controller.renderAtoms.selectedSymbol) + const table = this.#table + if (!table) return + const sizing = table.atoms.columnSizing.subscribe(() => + this.#writeColumnSizes(), + ) + const order = table.atoms.columnOrder.subscribe(() => + this.#writeColumnSizes(), + ) + const resizing = table.atoms.columnResizing.subscribe((state) => { + if (state.isResizingColumn !== false) this.#layout.manuallyResized = true + }) + const resizeObserver = new ResizeObserver(() => this.#fitAvailableWidth()) + if (this.#scrollRef.value) resizeObserver.observe(this.#scrollRef.value) + this.#cleanup.push( + () => sizing.unsubscribe(), + () => order.unsubscribe(), + () => resizing.unsubscribe(), + () => resizeObserver.disconnect(), + startTableBenchmark(this.controller), + ) + this.#writeColumnSizes() + this.#fitAvailableWidth() + this.#scheduleDomCommit() + } + protected updated() { + this.#writeColumnSizes() + this.#scheduleDomCommit() + } + disconnectedCallback() { + for (const cleanup of this.#cleanup) cleanup() + this.#cleanup.length = 0 + super.disconnectedCallback() + } + + #writeColumnSizes() { + const element = this.#tableRef.value + const table = this.#table + if (!element || !table) return + for (const header of table.getFlatHeaders()) { + element.style.setProperty( + `--header-${header.id}-size`, + String(header.getSize()), + ) + element.style.setProperty( + `--col-${header.column.id}-size`, + String(header.column.getSize()), + ) + } + element.style.width = `${table.getTotalSize()}px` + } + + #scheduleDomCommit() { + if (this.#domCommitScheduled) return + + this.#domCommitScheduled = true + queueMicrotask(() => { + this.#domCommitScheduled = false + if (this.isConnected) this.feed.completeRender() + }) + } + #fitAvailableWidth() { + const container = this.#scrollRef.value + const table = this.#table + if (!container || !table || this.#layout.manuallyResized) return + const width = table.getTotalSize() + if (container.clientWidth <= width + 1 || width <= 0) return + const ratio = container.clientWidth / width + table.setColumnSizing( + Object.fromEntries( + table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + #clearDrag = () => { + this.#drag.source?.classList.remove('is-column-dragging') + this.#drag.target?.classList.remove('is-column-drop-target') + this.#drag.columnId = null + this.#drag.source = null + this.#drag.target = null + } + #showDrop(columnId: string, element: HTMLTableCellElement | null) { + this.#drag.target?.classList.remove('is-column-drop-target') + this.#drag.target = null + if (this.#drag.columnId === columnId || !element) return + element.classList.add('is-column-drop-target') + this.#drag.target = element + } + + protected render() { + this.#columns ??= createTradingColumns(this.controller) + const table = this.#tableController.table( + { + key: 'lit-realtime-trading', + features, + columns: this.#columns, + data: this.feed.quotes.get(), + getRowId: (row) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }, + (state) => ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnOrder: state.columnOrder, + rowSelection: state.rowSelection, + cellSelection: state.cellSelection, + }), + ) + this.#table = table + const rows = readMeasuredRows(() => table.getRowModel().rows) + const benchmark = this.controller.store.get() + const virtualMode = resolveVirtualScrollMode( + benchmark.requestedVirtualScrollMode, + this.feed.instrumentCount.get(), + ) + const virtualizer = this.#virtualizer.getVirtualizer() + virtualizer.setOptions({ + ...virtualizer.options, + count: rows.length, + enabled: virtualMode === 'tanstack', + getItemKey: (index) => rows[index]?.id ?? index, + }) + const virtualRows = virtualizer.getVirtualItems() + this.controller.actions.setRenderedRowCount( + virtualMode === 'tanstack' ? virtualRows.length : rows.length, + ) + const range = virtualizer.range + const visibleRange = + virtualMode === 'tanstack' && range && rows.length + ? { + start: Math.min(range.startIndex, rows.length - 1), + end: Math.min(range.endIndex, rows.length - 1), + } + : null + const renderRow = ( + row: (typeof rows)[number], + virtualRow?: (typeof virtualRows)[number], + ) => + html` + ${repeat( + row.getVisibleCells(), + (cell) => cell.id, + (cell) => { + const edges = cell.getSelectionEdges() + return html` + ${FlexRender({ cell })} + ` + }, + )} + ` + return html`
+ handleCellNavigation(table, event)} + > + + ${repeat( + table.getHeaderGroups(), + (group) => group.id, + (group) => + html` + ${repeat( + group.headers, + (header) => header.id, + (header) => { + const leaf = header.subHeaders.length === 0 + const sorted = header.column.getIsSorted() + return html`` + }, + )} + `, + )} + + this.#pointer.handleMouseDown(table, event, this.controller.actions.selectSymbol)} + @pointerover=${(event: MouseEvent) => this.#pointer.handlePointerOver(table, event)} + @mouseleave=${() => this.#pointer.resetPointerCell()} + @click=${(event: MouseEvent) => this.#pointer.handleClick(table, event)} + > + ${ + virtualMode === 'tanstack' + ? repeat( + virtualRows, + (item) => item.key, + (item) => renderRow(rows[item.index], item), + ) + : repeat( + rows, + (row) => row.id, + (row) => renderRow(row), + ) + } + +
+ ${ + !header.isPlaceholder + ? leaf + ? html`
{ + event.preventDefault() + this.#showDrop( + header.column.id, + ( + event.currentTarget as HTMLElement + ).closest('th'), + ) + }} + @drop=${(event: DragEvent) => { + event.preventDefault() + const source = + event.dataTransfer?.getData( + 'text/plain', + ) || this.#drag.columnId + if (source) + table.setColumnOrder( + reorderColumnIds( + table + .getVisibleLeafColumns() + .map((column) => column.id), + source, + header.column.id, + ), + ) + this.#clearDrag() + }} + > + +
+ ${header.column.getCanResize() ? html`` : null}` + : FlexRender({ header }) + : null + } +
+
+ ${virtualMode === 'tanstack' ? html`
TanStack · Total · ${rows.length} rows · ${table.getVisibleLeafColumns().length} columns${visibleRange ? `Current · rows ${visibleRange.start}..${visibleRange.end}` : 'Current · rows —'}
` : null}` + } +} diff --git a/examples/lit/realtime-trading/src/table/table-config/quote-cells.ts b/examples/lit/realtime-trading/src/table/table-config/quote-cells.ts new file mode 100644 index 0000000000..e10aa97938 --- /dev/null +++ b/examples/lit/realtime-trading/src/table/table-config/quote-cells.ts @@ -0,0 +1,149 @@ +import { LitElement, html, svg } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +export const quoteCellLifecycle = { created: 0, destroyed: 0 } +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SparklineCell', +] as const +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] +const counters = (names: ReadonlyArray) => + Object.fromEntries(names.map((name) => [name, 0])) as Record +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: counters(quoteCellRendererNames), + componentRenderCallsByName: counters(quoteComponentNames), +} +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} +const signed = (value: number) => `${value >= 0 ? '+' : ''}${value.toFixed(2)}` + +abstract class QuoteElement extends LitElement { + protected abstract readonly componentName: QuoteComponentName + protected createRenderRoot() { + return this + } + connectedCallback() { + super.connectedCallback() + quoteCellLifecycle.created++ + } + disconnectedCallback() { + quoteCellLifecycle.destroyed++ + super.disconnectedCallback() + } + protected recordRender() { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[this.componentName]++ + } +} + +@customElement('quote-price-cell') +export class PriceCell extends QuoteElement { + protected readonly componentName = 'PriceCell' + @property({ type: Number }) price = 0 + @property({ type: Number }) move = 0 + @property({ attribute: false }) select: () => void = () => undefined + protected render() { + this.recordRender() + return html`` + } +} + +abstract class MoveCell extends QuoteElement { + @property({ type: Number }) move = 0 + protected direction: 'up' | 'down' | null = null + protected indicator = '' + protected render() { + this.recordRender() + const direction = this.direction ?? (this.move >= 0 ? 'up' : 'down') + return html`${this.indicator}${signed(this.move)}` + } +} +@customElement('quote-stable-move') +export class StableMoveCell extends MoveCell { + protected readonly componentName = 'StableMoveCell' +} +@customElement('quote-up-move') +export class UpMoveCell extends MoveCell { + protected readonly componentName = 'UpMoveCell' + protected direction = 'up' as const + protected indicator = '▲ ' +} +@customElement('quote-down-move') +export class DownMoveCell extends MoveCell { + protected readonly componentName = 'DownMoveCell' + protected direction = 'down' as const + protected indicator = '▼ ' +} + +@customElement('quote-percent-change') +export class PercentChangeCell extends QuoteElement { + protected readonly componentName = 'PercentChangeCell' + @property({ type: Number }) value = 0 + protected render() { + this.recordRender() + return html`${signed(this.value)}%` + } +} + +@customElement('quote-sparkline') +export class SparklineCell extends QuoteElement { + protected readonly componentName = 'SparklineCell' + @property({ attribute: false }) values: ReadonlyArray = [] + protected render() { + this.recordRender() + const first = this.values[0] ?? 0 + const range = this.values.reduce( + (current, value) => ({ + min: Math.min(current.min, value), + max: Math.max(current.max, value), + }), + { min: first, max: first }, + ) + const height = range.max - range.min || 1 + const denominator = Math.max(1, this.values.length - 1) + const points = this.values + .map( + (value, index) => + `${((index / denominator) * 100).toFixed(1)},${(22 - ((value - range.min) / height) * 20).toFixed(1)}`, + ) + .join(' ') + const rising = (this.values.at(-1) ?? 0) >= first + return svg`` + } +} diff --git a/examples/lit/realtime-trading/src/table/table-config/trading-columns.ts b/examples/lit/realtime-trading/src/table/table-config/trading-columns.ts new file mode 100644 index 0000000000..7b72798b74 --- /dev/null +++ b/examples/lit/realtime-trading/src/table/table-config/trading-columns.ts @@ -0,0 +1,254 @@ +import { html } from 'lit' +import { recordCellRender } from './quote-cells' +import type { ColumnDef, TableFeatures } from '@tanstack/lit-table' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} +interface TradingCellContext { + row: { original: MarketQuote } +} +interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => unknown +} +const compact = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function createTradingColumns( + controller: TradingBenchmarkController, +): Array> { + const columns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender( + 'Last', + html` controller.actions.selectSymbol(row.original.symbol)} + >`, + ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: getDayChange, + cell: ({ row }) => + recordCellRender( + 'Change', + renderMove( + controller.renderAtoms.rendererMode.get(), + getDayChange(row.original), + ), + ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: getDayChangePercent, + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + html``, + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => + recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender('BidVolume', compact.format(row.original.bidSize)), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => + recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender('AskVolume', compact.format(row.original.askSize)), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => + recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender( + 'Intraday', + html``, + ), + }, + ], + }, + ] + return columns as unknown as Array> +} + +function renderMove(mode: RendererMode, move: number) { + if (mode === 'stable') + return html`` + return move >= 0 + ? html`` + : html`` +} + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} +export const TRADING_COLUMN_COUNT = 14 +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 1_000 === 0) + performance.clearMeasures('tanstack-row-model') + } catch { + /* optional sampled User Timing detail */ + } + } + return rows +} +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/lit/realtime-trading/src/table/table-interactions.ts b/examples/lit/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..99649a40bd --- /dev/null +++ b/examples/lit/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,187 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/lit/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/lit/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/lit/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/lit/realtime-trading/src/table/trading-table.ts b/examples/lit/realtime-trading/src/table/trading-table.ts new file mode 100644 index 0000000000..77e4e6bee1 --- /dev/null +++ b/examples/lit/realtime-trading/src/table/trading-table.ts @@ -0,0 +1,9 @@ +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-columns' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns' +export type { VirtualScrollMode } from './trading-row-virtualizer' diff --git a/examples/lit/realtime-trading/src/vite-env.d.ts b/examples/lit/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/lit/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts b/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..84b6ba3915 --- /dev/null +++ b/examples/lit/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Lit realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/lit/realtime-trading/tsconfig.json b/examples/lit/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..91eacfe230 --- /dev/null +++ b/examples/lit/realtime-trading/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "experimentalDecorators": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/lit/realtime-trading/vite.config.ts b/examples/lit/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..a72243cea7 --- /dev/null +++ b/examples/lit/realtime-trading/vite.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vite' +export default defineConfig({ server: { port: 7783, allowedHosts: true } }) diff --git a/examples/octane/realtime-trading/README.md b/examples/octane/realtime-trading/README.md new file mode 100644 index 0000000000..c73c9bc2e9 --- /dev/null +++ b/examples/octane/realtime-trading/README.md @@ -0,0 +1,158 @@ +# Octane realtime trading benchmark + +This standalone example exercises the current TanStack Octane Table adapter +with a high-frequency worker feed, immutable snapshots, interactive columns, +custom TSRX cells, Virtual Core, and browser diagnostics. It is a repeatable UI +stress workload rather than an exchange/network simulator. + +## Run and verify + +```bash +pnpm --dir examples/octane/realtime-trading dev +``` + +Open `http://localhost:7786`. + +```bash +pnpm --dir examples/octane/realtime-trading test:types +pnpm --dir examples/octane/realtime-trading lint +pnpm --dir examples/octane/realtime-trading build +pnpm --dir examples/octane/realtime-trading test:e2e +``` + +Use a production build for representative measurements. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `src/feed/` | Market model, instruments, feed config, immutable update helpers, and direct TanStack atom feed controller. | +| `src/feed/worker/` | Typed protocol, deterministic engine, and module worker. | +| `src/benchmark/` | Browser monitor, benchmark controller, table observer, and row-model timing. | +| `src/shell/` | Separate TSRX header, metrics, configurator, diagnostics, selected-instrument, status, and shell components. | +| `src/table/table-config/` | Grouped columns and custom TSRX quote components. | +| `src/table/` | Octane Table view/setup, interactions, pointer hook, column layout, and Virtual Core hook. | +| `src/use-store-value.ts` | Small Octane hooks that subscribe to an atom, a whole store, or a compared selector slice. | +| `src/main.tsrx` | Creates controllers and renders the shell composition root. | + +`MarketFeedController` owns feed/worker state. `TradingBenchmarkController` +observes it and owns diagnostic/view state. The shell receives stable controller +objects; individual components call `useStoreSelector` or `useStoreValue` for +the values they need. Quotes, feed status, row count, workload, delivery, and +chart controls are direct independent atoms. The table subscribes to `quotes` +and `instrumentCount`, so a configurator/status update cannot invalidate table +data. Benchmark metrics remain an aggregate snapshot store. + +## Feed and worker pipeline + +Defaults are 100 instruments, 10K generated samples/s, 20 ms delivery, enabled +intraday charts, and 16 ms chart sampling. + +The worker keeps mutable quote state private. A deterministic 16 ms budget loop +generates samples, and a row-indexed `Map` coalesces repeated updates. A separate +publication timer posts the latest unique rows. The main thread creates a new +outer array and replaces only changed row objects; untouched rows and unsampled +history arrays retain their references. Session IDs discard stale batches after +reset or instrument-count changes. + +- **Synthetic quote workload** means generated worker samples/s, not messages, + events, or component renders. +- **Worker delivery interval** controls coalesced message cadence; 20 ms targets + about 50 messages/s. +- **Row updates** counts unique immutable rows applied on the main thread. +- **Message samples** is the generated work represented by the latest message. + +The 25K burst immediately generates and flushes one deliberately heavy batch. +The worker approximates an upstream stream but does not measure network delay. + +## Octane table architecture + +The table has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart. Sorting/filtering, on-change resizing, double-click +reset, drag ordering, CSS row hover, row selection, drag cell ranges, keyboard +navigation, and custom Price/Move/Percent/Sparkline components are included. + +Stable instrument IDs back `getRowId`. Table, row, header, and quote component +boundaries keep ownership explicit. A local hook delegates pointer handling at +the body and stores transient drag state without publishing render state. It +resolves a cell through `event.composedPath()` and data attributes rather than +installing per-cell listeners. + +Column widths are table CSS variables updated only for sizing/order. A +`ResizeObserver` performs the initial fit and stops auto-fitting after manual +resize. The move A/B option deliberately changes component type to measure +creation/destruction; stable mode is the realistic baseline. + +## Virtualization + +- Below 200 rows, automatic mode resolves to Full DOM, while Virtual is + selectable. +- From 200 through 1,499 rows, automatic mode resolves to TanStack Virtual and + Full DOM remains selectable. +- At 1,500 rows or more, Virtual is forced and the control is locked. + +The local `useVirtualizer` bridge owns one `@tanstack/virtual-core` instance. +It updates options in place, mounts/unmounts through Octane layout effects, and +increments a local render version on virtualizer changes. Configuration uses a +32 px estimate, 10-row overscan, row IDs as keys, transformed rows, and a spacer +body. The footer reads the Virtualizer range. + +Both paths use `content-visibility: auto`. This can reduce offscreen browser +work in Full DOM but does not prevent Octane from creating every row/cell. + +## Performance decisions + +- worker generation and coalescing before main-thread delivery; +- immutable structural sharing for rows and chart histories; +- selector-based subscriptions with explicit equality; +- stable table and virtual row identity; +- componentized shell/table/cells with local state ownership; +- delegated pointer input and CSS hover; +- CSS variables for column sizes; +- opt-in component churn and independently sampled charts; +- virtual mounting for larger data sets; +- benchmark publication slower than feed updates. + +The outer array intentionally changes for immutable publication. Structural +sharing reduces renderer work, but sorted/filtered row models may still execute +when the data input changes. + +## Diagnostics and interpretation + +The compact **Live health** section lives in the configurator and reports the +estimated frame callback rate over 1 second, average market-mutation-to-DOM- +commit latency over 3 seconds, cumulative long animation frames, and throughput +as changed rows plus applied snapshots per second. Detailed diagnostics retain +worker samples/messages, state applies, DOM commits, rolling 10-second p95/max +commit latency, cumulative slow commits, mounted cells, component +lifecycle/execution, row-model timing, DOM mutation records, and heap. + +Octane's completed-render measurement is connected to the feed mutation and +the table's committed layout lifecycle; it should be compared using the same +build and settings. The frame value counts `requestAnimationFrame` callbacks, +not GPU-presented frames. `MutationObserver` reports delivered records, not +individual DOM operations, and adds overhead on a hot subtree; it is limited to +text, child-list, and `class`/`style` changes. Heap is Chromium-only and +GC-sensitive. User Timing timeline measures are sampled 1-in-20 while the +in-memory latency calculation keeps every commit. Callback execution does not +equal DOM mutation, and heap growth is not a confirmed leak without post-GC +retention. +The rAF loop only appends a timestamp; rolling aggregation and heap reads run at +the 500 ms metrics publication cadence. Mutation observation remains the most +intrusive diagnostic because the browser must create records for the observed +subtree. + +`Changed rows/s` sums the update array lengths delivered by each snapshot. +Symbols are deduplicated inside one message, but the same row can count again in +the next snapshot; it is applied row throughput, not distinct instruments per +second. + +## Standalone policy + +This folder owns copies of its feed, worker, instruments, benchmark, shell, +styles, and table implementation. That duplication keeps it independently +runnable and StackBlitz-friendly; shared README explanations are repeated +across adapters intentionally. + +The workspace resolves `@tanstack/octane-table` to the repository adapter while +the example manifest remains release-like. diff --git a/examples/octane/realtime-trading/index.html b/examples/octane/realtime-trading/index.html new file mode 100644 index 0000000000..ddd15fcca9 --- /dev/null +++ b/examples/octane/realtime-trading/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Octane Table + + +
+ + + diff --git a/examples/octane/realtime-trading/package.json b/examples/octane/realtime-trading/package.json new file mode 100644 index 0000000000..3f046665f1 --- /dev/null +++ b/examples/octane/realtime-trading/package.json @@ -0,0 +1,25 @@ +{ + "name": "tanstack-octane-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsrx-tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/octane-table": "^9.1.2", + "@tanstack/store": "^0.11.0", + "@tanstack/virtual-core": "^3.13.36", + "octane": "0.1.21" + }, + "devDependencies": { + "@tsrx/typescript-plugin": "^0.3.118", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/octane/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/octane/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..36ea2ddc50 --- /dev/null +++ b/examples/octane/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,402 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells.tsrx' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableRendersPerSecond: number + lastBatchSize: number + averageRenderMs: number + p95RenderMs: number + maxRenderMs: number + slowRenders: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableRendersPerSecond: 0, + lastBatchSize: 0, + averageRenderMs: 0, + p95RenderMs: 0, + maxRenderMs: 0, + slowRenders: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCallCount: 0 } +const USER_TIMING_SAMPLE_INTERVAL = 20 + +interface CommitLatencySample { + recordedAt: number + duration: number +} + +const AVERAGE_COMMIT_WINDOW_MS = 3_000 +const PERCENTILE_COMMIT_WINDOW_MS = 10_000 +const FRAME_RATE_WINDOW_MS = 1_000 + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCallCount++ + if (userTiming.measureCallCount % USER_TIMING_SAMPLE_INTERVAL !== 0) return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + frameTrackingStartedAt: performance.now(), + pendingRenderStartedAt: null as number | null, + renderSamples: [] as Array, + frameTimestamps: [] as Array, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + tableRendersInSample: 0, + slowRenderCount: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markRenderPending(): void { + this.#runtime.pendingRenderStartedAt ??= performance.now() + } + + recordCompletedRender(): void { + const runtime = this.#runtime + if (runtime.pendingRenderStartedAt !== null) { + const renderEndedAt = performance.now() + const duration = renderEndedAt - runtime.pendingRenderStartedAt + runtime.renderSamples.push({ recordedAt: renderEndedAt, duration }) + if (duration > 16.7) runtime.slowRenderCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingRenderStartedAt, + renderEndedAt, + {}, + ) + runtime.pendingRenderStartedAt = null + runtime.tableRendersInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + this.#runtime.frameTimestamps.push(now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + runtime.renderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - PERCENTILE_COMMIT_WINDOW_MS, + ) + runtime.frameTimestamps = runtime.frameTimestamps.filter( + (timestamp) => timestamp >= now - FRAME_RATE_WINDOW_MS, + ) + const averageRenderSamples = runtime.renderSamples.filter( + (sample) => sample.recordedAt >= now - AVERAGE_COMMIT_WINDOW_MS, + ) + const sortedRenderSamples = runtime.renderSamples + .map((sample) => sample.duration) + .sort((left, right) => left - right) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageRenderMs = + averageRenderSamples.length === 0 + ? 0 + : averageRenderSamples.reduce( + (sum, sample) => sum + sample.duration, + 0, + ) / averageRenderSamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedRenderSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: + (runtime.frameTimestamps.length / + Math.min( + FRAME_RATE_WINDOW_MS, + Math.max(1, now - runtime.frameTrackingStartedAt), + )) * + 1_000, + tableRendersPerSecond: + (runtime.tableRendersInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageRenderMs, + p95RenderMs: sortedRenderSamples[p95Index] ?? 0, + maxRenderMs: sortedRenderSamples.at(-1) ?? 0, + slowRenders: runtime.slowRenderCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableRendersInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingRenderStartedAt = null + runtime.renderSamples = [] + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.tableRendersInSample = 0 + runtime.slowRenderCount = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} diff --git a/examples/octane/realtime-trading/src/benchmark/table-benchmark.ts b/examples/octane/realtime-trading/src/benchmark/table-benchmark.ts new file mode 100644 index 0000000000..ee076b605f --- /dev/null +++ b/examples/octane/realtime-trading/src/benchmark/table-benchmark.ts @@ -0,0 +1,20 @@ +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function startTableBenchmark( + controller: TradingBenchmarkController, + tableBody: HTMLTableSectionElement | null, +): () => void { + if (!tableBody) return () => undefined + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() +} diff --git a/examples/octane/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/octane/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..c3d1db616b --- /dev/null +++ b/examples/octane/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markRenderPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordCompletedRender(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/octane/realtime-trading/src/feed/feed-sample-rates.ts b/examples/octane/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/octane/realtime-trading/src/feed/market-data.ts b/examples/octane/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/octane/realtime-trading/src/feed/market-feed-config.ts b/examples/octane/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/octane/realtime-trading/src/feed/market-feed-controller.ts b/examples/octane/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..40f523562d --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/octane/realtime-trading/src/feed/market-instruments.ts b/examples/octane/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/octane/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/octane/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/octane/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/octane/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/octane/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/octane/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/octane/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/octane/realtime-trading/src/index.css b/examples/octane/realtime-trading/src/index.css new file mode 100644 index 0000000000..82328b533e --- /dev/null +++ b/examples/octane/realtime-trading/src/index.css @@ -0,0 +1,1065 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +.diagnostic-note { + margin: 0.5rem 0 0; + color: var(--muted); + font-size: 0.56rem; + line-height: 1.45; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/octane/realtime-trading/src/main.tsrx b/examples/octane/realtime-trading/src/main.tsrx new file mode 100644 index 0000000000..d7b6eaee20 --- /dev/null +++ b/examples/octane/realtime-trading/src/main.tsrx @@ -0,0 +1,12 @@ +import { createRoot } from 'octane' +import { TradingBenchmarkController } from './benchmark/trading-benchmark-controller' +import { MarketFeedController } from './feed/market-feed-controller' +import { TradingShell } from './shell/TradingShell.tsrx' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +const feed = new MarketFeedController() +const controller = new TradingBenchmarkController(feed) +createRoot(rootElement).render(() => ) diff --git a/examples/octane/realtime-trading/src/shell/AppHeader.tsrx b/examples/octane/realtime-trading/src/shell/AppHeader.tsrx new file mode 100644 index 0000000000..d5bd0cdce6 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/AppHeader.tsrx @@ -0,0 +1,30 @@ +import { useStoreValue } from '../use-store-value' +import type { MarketFeedController } from '../feed/market-feed-controller' + +export function AppHeader(props: { + feed: MarketFeedController + sidebarOpen: boolean + toggleSidebar: () => void +}) @{ + const workerReady = useStoreValue(props.feed.workerReady) + const running = useStoreValue(props.feed.running) + const status = !workerReady ? 'FEED CONNECTING' : running ? 'FEED LIVE' : 'FEED PAUSED' +
+
MARKET MONITOR
+
+ + + +
+
+} diff --git a/examples/octane/realtime-trading/src/shell/Configurator.tsrx b/examples/octane/realtime-trading/src/shell/Configurator.tsrx new file mode 100644 index 0000000000..7ecc319378 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/Configurator.tsrx @@ -0,0 +1,131 @@ +import { feedSampleRateAt, feedSampleRateIndex, feedSampleRateOptions } from '../feed/feed-sample-rates' +import { FORCED_VIRTUALIZATION_ROW_COUNT, resolveVirtualScrollMode } from '../table/trading-row-virtualizer' +import { useStoreSelector, useStoreValue } from '../use-store-value' +import { configuratorOptions } from './configurator-options' +import { Diagnostics } from './Diagnostics.tsrx' +import { MetricsStrip } from './MetricsStrip.tsrx' +import { SelectedInstrument } from './SelectedInstrument.tsrx' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController, TradingBenchmarkState } from '../benchmark/trading-benchmark-controller' + +const rate = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }) +const selectRequestedVirtualScrollMode = (state: TradingBenchmarkState) => + state.requestedVirtualScrollMode + +export function Configurator(props: { + controller: TradingBenchmarkController + feed: MarketFeedController +}) @{ + const running = useStoreValue(props.feed.running) + const instrumentCount = useStoreValue(props.feed.instrumentCount) + const targetTicksPerSecond = useStoreValue(props.feed.targetTicksPerSecond) + const publishIntervalMs = useStoreValue(props.feed.publishIntervalMs) + const updateSparklines = useStoreValue(props.feed.updateSparklines) + const sparklineSampleIntervalMs = useStoreValue( + props.feed.sparklineSampleIntervalMs, + ) + const requestedVirtualScrollMode = useStoreSelector( + props.controller.store, + selectRequestedVirtualScrollMode, + Object.is, + ) + const rendererMode = useStoreValue(props.controller.renderAtoms.rendererMode) + const forced = instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT + const virtualMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + const value = (event: Event): string => (event.target as HTMLInputElement | HTMLSelectElement).value + const number = (event: Event): number => Number(value(event)) + const checked = (event: Event): boolean => (event.target as HTMLInputElement).checked + + +} diff --git a/examples/octane/realtime-trading/src/shell/Diagnostics.tsrx b/examples/octane/realtime-trading/src/shell/Diagnostics.tsrx new file mode 100644 index 0000000000..eed46206fe --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/Diagnostics.tsrx @@ -0,0 +1,51 @@ +import { useStoreValue } from '../use-store-value' +import type { NamedInvocationRate } from '../benchmark/benchmark-monitor' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const rate = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }) +const ms = (value: number): string => `${value.toFixed(2)} ms` +const invocations = (values: ReadonlyArray): string => { + const active = values.filter((entry) => entry.callsPerSecond > 0) + return active.length ? active.map((entry) => `${entry.name} ${rate.format(entry.callsPerSecond)}`).join(' · ') : '—' +} + +export function Diagnostics({ controller }: { controller: TradingBenchmarkController }) @{ + const state = useStoreValue(controller.store) + const metrics = state.metrics + const items = [ + ['Mounted cells', integer.format(state.mountedCells), ''], + ['Live components', integer.format(state.liveComponents), ''], + ['Created / destroyed', `${integer.format(metrics.componentsCreated)} / ${integer.format(metrics.componentsDestroyed)}`, ''], + ['Renderer callbacks / s', rate.format(metrics.cellRendererCallsPerSecond), 'cell-render-rate'], + ['Component executions / s', rate.format(metrics.componentRenderCallsPerSecond), 'component-render-rate'], + ['Executions by component / s', invocations(metrics.componentRenderRates), 'component-render-breakdown'], + ['Callbacks by column / s', invocations(metrics.cellRendererRates), 'cell-render-breakdown'], + ['Observed MutationRecords / s', rate.format(metrics.domMutationsPerSecond), 'dom-mutation-rate'], + ['Worker samples / s', rate.format(metrics.actualTicksPerSecond), 'actual-rate'], + ['Worker messages / s', metrics.workerMessagesPerSecond.toFixed(1), 'message-rate'], + ['Changed rows / s', rate.format(metrics.rowUpdatesPerSecond), 'row-update-rate'], + ['State snapshots / s', metrics.stateApplicationsPerSecond.toFixed(1), 'state-apply-rate'], + ['Table DOM commits / s', metrics.tableRendersPerSecond.toFixed(1), 'table-render-rate'], + ['P95 / max commit latency (rolling 10 s)', `${ms(metrics.p95RenderMs)} / ${ms(metrics.maxRenderMs)}`, ''], + ['Core row model calls / s', metrics.rowModelCallsPerSecond.toFixed(1), 'row-model-call-rate'], + ['Core row model avg / max', `${ms(metrics.rowModelAverageMs)} / ${ms(metrics.rowModelMaxMs)}`, 'row-model-duration'], + ['Visible rows', integer.format(metrics.visibleRows), 'visible-row-count'], + ['Worker messages since reset', integer.format(metrics.workerMessages), 'worker-messages'], + ['Worker-coalesced updates / s', rate.format(metrics.supersededUpdatesPerSecond), 'superseded-update-rate'], + ['Last samples / updated rows', `${integer.format(metrics.lastBatchSize)} / ${integer.format(metrics.lastUpdateCount)}`, ''], + ['Commits > 16.7 ms since reset', integer.format(metrics.slowRenders), ''], + ['JS heap (GC-sensitive)', metrics.heapMb === null ? 'N/A' : `${metrics.heapMb.toFixed(1)} MB`, ''], + ] as const +
+

DIAGNOSTICS

+
+ @for (const item of items; key item[0]) { +
{item[0]}
{item[1]}
+ } +
+

+ MutationObserver counts delivered records, not individual DOM operations, and adds profiling overhead. Only class/style attributes, text, and child-list changes are observed. Heap is a Chromium-only point-in-time value and can move before garbage collection. +

+
+} diff --git a/examples/octane/realtime-trading/src/shell/MarketStatusbar.tsrx b/examples/octane/realtime-trading/src/shell/MarketStatusbar.tsrx new file mode 100644 index 0000000000..ccda1d9fd9 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/MarketStatusbar.tsrx @@ -0,0 +1,24 @@ +import { shallowEqual, useStoreSelector } from '../use-store-value' +import type { TradingBenchmarkController, TradingBenchmarkState } from '../benchmark/trading-benchmark-controller' + +const integer = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) +const selectStatusbarState = (state: TradingBenchmarkState) => ({ + lastBatchSize: state.metrics.lastBatchSize, + lastUpdateCount: state.metrics.lastUpdateCount, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, +}) + +export function MarketStatusbar({ controller }: { controller: TradingBenchmarkController }) @{ + const state = useStoreSelector( + controller.store, + selectStatusbarState, + shallowEqual, + ) +
+ MESSAGE SAMPLES {integer.format(state.lastBatchSize)} + CHANGED ROWS {integer.format(state.lastUpdateCount)} + HOSTS {integer.format(state.mountedCells)} + COMPONENTS {integer.format(state.liveComponents)} +
+} diff --git a/examples/octane/realtime-trading/src/shell/MetricsStrip.tsrx b/examples/octane/realtime-trading/src/shell/MetricsStrip.tsrx new file mode 100644 index 0000000000..f9be39923a --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/MetricsStrip.tsrx @@ -0,0 +1,22 @@ +import { useStoreValue } from '../use-store-value' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const rate = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }) +const ms = (value: number): string => `${value.toFixed(2)} ms` + +export function MetricsStrip({ controller }: { controller: TradingBenchmarkController }) @{ + const state = useStoreValue(controller.store) + const metrics = state.metrics + const items = [ + ['FRAME RATE (EST.)', metrics.rafCallbacksPerSecond.toFixed(1), 'rAF callbacks/s · rolling 1 s', 'frame-rate'], + ['AVG COMMIT', ms(metrics.averageRenderMs), 'snapshot → DOM · rolling 3 s', 'average-commit-latency'], + ['LONG FRAMES', state.longAnimationFramesSupported ? String(metrics.longAnimationFrames) : 'N/A', state.longAnimationFramesSupported ? `since reset · worst ${ms(metrics.worstLongAnimationFrameMs)}` : 'unsupported', 'long-frame-count'], + ['THROUGHPUT', `${rate.format(metrics.rowUpdatesPerSecond)} rows/s`, `${metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows deduplicated per snapshot`, 'throughput-rate'], + ] as const +
+

LIVE HEALTH

+ @for (const item of items; key item[0]) { +
{item[0]}{item[1]}{item[2]}
+ } +
+} diff --git a/examples/octane/realtime-trading/src/shell/SelectedInstrument.tsrx b/examples/octane/realtime-trading/src/shell/SelectedInstrument.tsrx new file mode 100644 index 0000000000..63cfd83348 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/SelectedInstrument.tsrx @@ -0,0 +1,19 @@ +import { useStoreValue } from '../use-store-value' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +export function SelectedInstrument(props: { + controller: TradingBenchmarkController + feed: MarketFeedController +}) @{ + const quotes = useStoreValue(props.feed.quotes) + const symbol = useStoreValue(props.controller.renderAtoms.selectedSymbol) + const quote = props.feed.getQuoteBySymbol(quotes, symbol) +
+

SELECTED INSTRUMENT

+ {quote ? <> +
{quote.symbol}{quote.company}
{quote.venue}
+
Last
{quote.price.toFixed(2)}
Bid / ask
{quote.bid.toFixed(2)} / {quote.ask.toFixed(2)}
+ :

Click or begin a cell selection in any row to inspect its instrument.

} +
+} diff --git a/examples/octane/realtime-trading/src/shell/TradingShell.tsrx b/examples/octane/realtime-trading/src/shell/TradingShell.tsrx new file mode 100644 index 0000000000..d0cec4fa46 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/TradingShell.tsrx @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'octane' +import { AppHeader } from './AppHeader.tsrx' +import { Configurator } from './Configurator.tsrx' +import { MarketStatusbar } from './MarketStatusbar.tsrx' +import { TradingTable } from '../table/TradingTable.tsrx' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +export function TradingShell(props: { + controller: TradingBenchmarkController + feed: MarketFeedController +}) @{ + const [sidebarOpen, setSidebarOpen] = useState(true) + useEffect(() => { + const stopFeed = props.feed.start() + const stopBenchmark = props.controller.start() + return () => { + stopBenchmark() + stopFeed() + } + }, [props.controller, props.feed]) + +
+
+ setSidebarOpen((open) => !open)} /> + {import.meta.env.DEV && } +
+
+ +
+ +
+ {sidebarOpen && } +
+
+} diff --git a/examples/octane/realtime-trading/src/shell/configurator-options.ts b/examples/octane/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/octane/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/octane/realtime-trading/src/table/TradingTable.tsrx b/examples/octane/realtime-trading/src/table/TradingTable.tsrx new file mode 100644 index 0000000000..d4f194c795 --- /dev/null +++ b/examples/octane/realtime-trading/src/table/TradingTable.tsrx @@ -0,0 +1,300 @@ +import { useLayoutEffect, useMemo, useRef } from 'octane' +import { + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, + useTable, +} from '@tanstack/octane-table' +import { startTableBenchmark } from '../benchmark/table-benchmark' +import { useStoreSelector, useStoreValue } from '../use-store-value' +import { createTradingColumns, readMeasuredRows } from './table-config/trading-columns.tsrx' +import { handleCellNavigation, reorderColumnIds, sortAriaValue, sortIndicator } from './table-interactions' +import { TRADING_ROW_HEIGHT, TRADING_ROW_OVERSCAN, resolveVirtualScrollMode, useVirtualizer } from './trading-row-virtualizer' +import { useTradingGridPointer } from './use-trading-grid-pointer' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { MarketQuote } from '../feed/market-data' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' +import type { TradingBenchmarkState } from '../benchmark/trading-benchmark-controller' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) + +const selectRequestedVirtualScrollMode = (state: TradingBenchmarkState) => + state.requestedVirtualScrollMode + +interface TradingTableProps { + controller: TradingBenchmarkController + feed: MarketFeedController +} + +export function TradingTable({ controller, feed }: TradingTableProps) @{ + const quotes = useStoreValue(feed.quotes) + const instrumentCount = useStoreValue(feed.instrumentCount) + const requestedVirtualScrollMode = useStoreSelector( + controller.store, + selectRequestedVirtualScrollMode, + Object.is, + ) + const rendererMode = useStoreValue(controller.renderAtoms.rendererMode) + const selectedSymbol = useStoreValue(controller.renderAtoms.selectedSymbol) + const columns = useMemo( + () => createTradingColumns(controller, rendererMode), + [controller, rendererMode], + ) + const table = useTable( + { + features, + columns, + data: quotes, + getRowId: (row: MarketQuote) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }, + (state) => ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnOrder: state.columnOrder, + rowSelection: state.rowSelection, + cellSelection: state.cellSelection, + }), + ) + const rows = readMeasuredRows(() => table.getRowModel().rows) + const virtualMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + const scrollRef = useRef(null) + const tableRef = useRef(null) + const bodyRef = useRef(null) + const manuallyResized = useRef(false) + const pointer = useTradingGridPointer(table, controller.actions.selectSymbol) + const drag = useRef({ + columnId: null as string | null, + source: null as HTMLTableCellElement | null, + target: null as HTMLTableCellElement | null, + }) + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => TRADING_ROW_HEIGHT, + overscan: TRADING_ROW_OVERSCAN, + enabled: virtualMode === 'tanstack', + getItemKey: (index) => rows[index]?.id ?? index, + }) + const virtualRows = virtualMode === 'tanstack' ? virtualizer.getVirtualItems() : [] + const renderedRowCount = virtualMode === 'tanstack' ? virtualRows.length : rows.length + const range = virtualizer.range + const visibleRange = virtualMode === 'tanstack' && range && rows.length > 0 + ? { start: Math.min(range.startIndex, rows.length - 1), end: Math.min(range.endIndex, rows.length - 1) } + : null + + useLayoutEffect(() => { + controller.actions.setRenderedRowCount(renderedRowCount) + feed.completeRender() + }, null) + + useLayoutEffect(() => { + const writeColumnSizes = () => { + const element = tableRef.current + if (!element) return + for (const header of table.getFlatHeaders()) { + element.style.setProperty(`--header-${header.id}-size`, String(header.getSize())) + element.style.setProperty(`--col-${header.column.id}-size`, String(header.column.getSize())) + } + element.style.width = `${table.getTotalSize()}px` + } + const fitAvailableWidth = () => { + const container = scrollRef.current + if (!container || manuallyResized.current) return + const width = table.getTotalSize() + if (container.clientWidth <= width + 1 || width <= 0) return + const ratio = container.clientWidth / width + table.setColumnSizing(Object.fromEntries( + table.getVisibleLeafColumns().map((column) => [column.id, column.getSize() * ratio]), + )) + } + const sizing = table.atoms.columnSizing.subscribe(writeColumnSizes) + const order = table.atoms.columnOrder.subscribe(writeColumnSizes) + const resizing = table.atoms.columnResizing.subscribe((state) => { + if (state.isResizingColumn !== false) manuallyResized.current = true + }) + const resizeObserver = new ResizeObserver(fitAvailableWidth) + if (scrollRef.current) resizeObserver.observe(scrollRef.current) + writeColumnSizes() + fitAvailableWidth() + const stopBenchmark = startTableBenchmark(controller, bodyRef.current) + return () => { + sizing.unsubscribe() + order.unsubscribe() + resizing.unsubscribe() + resizeObserver.disconnect() + stopBenchmark() + } + }, [controller, table.store]) + + const clearDrag = () => { + drag.current.source?.classList.remove('is-column-dragging') + drag.current.target?.classList.remove('is-column-drop-target') + drag.current.columnId = null + drag.current.source = null + drag.current.target = null + } + const showDropTarget = (columnId: string, element: HTMLTableCellElement | null) => { + drag.current.target?.classList.remove('is-column-drop-target') + drag.current.target = null + if (drag.current.columnId === columnId || !element) return + element.classList.add('is-column-drop-target') + drag.current.target = element + } + const renderRow = ( + row: (typeof rows)[number], + virtualRow?: { index: number; start: number }, + ) => ( + + @for (const cell of row.getVisibleCells(); key cell.id) { + const edges = cell.getSelectionEdges() + + + + } + + ) + + <> +
+ handleCellNavigation(table, event)} + > + + @for (const group of table.getHeaderGroups(); key group.id) { + + @for (const header of group.headers; key header.id) { + const leaf = header.subHeaders.length === 0 + const sorted = header.column.getIsSorted() + + } + + } + + + {virtualMode === 'tanstack' + ? virtualRows.map((item) => renderRow(rows[item.index]!, item)) + : rows.map((row) => renderRow(row))} + +
+ {!header.isPlaceholder && (leaf ? <> +
{ + event.preventDefault() + showDropTarget(header.column.id, event.currentTarget.closest('th')) + }} + onDrop={(event) => { + event.preventDefault() + const source = event.dataTransfer?.getData('text/plain') || drag.current.columnId + if (source) { + table.setColumnOrder(reorderColumnIds( + table.getVisibleLeafColumns().map((column) => column.id), + source, + header.column.id, + )) + } + clearDrag() + }} + > + + +
+ {header.column.getCanResize() &&
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + />} + : )} +
+
+ {virtualMode === 'tanstack' &&
+ TanStack · Total · {rows.length} rows · {table.getVisibleLeafColumns().length} columns + {visibleRange ? `Current · rows ${visibleRange.start}..${visibleRange.end}` : 'Current · rows —'} +
} + +} diff --git a/examples/octane/realtime-trading/src/table/table-config/quote-cells.tsrx b/examples/octane/realtime-trading/src/table/table-config/quote-cells.tsrx new file mode 100644 index 0000000000..2ca5ecf8e6 --- /dev/null +++ b/examples/octane/realtime-trading/src/table/table-config/quote-cells.tsrx @@ -0,0 +1,94 @@ +import { useEffect } from 'octane' + +export const quoteCellLifecycle = { created: 0, destroyed: 0 } + +export const quoteCellRendererNames = [ + 'Market', 'Name', 'Symbol', 'Last', 'Change', 'ChangePercent', 'Bid', + 'BidVolume', 'Ask', 'AskVolume', 'Open', 'High', 'Low', 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', 'StableMoveCell', 'UpMoveCell', 'DownMoveCell', + 'PercentChangeCell', 'SpreadCell', 'DepthCell', 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = (names: ReadonlyArray) => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +function useLifecycleCounter(name: QuoteComponentName): void { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[name]++ + useEffect(() => { + quoteCellLifecycle.created++ + return () => { quoteCellLifecycle.destroyed++ } + }, []) +} + +export function PriceCell(props: { price: number; move: number; onSelect: () => void }) @{ + useLifecycleCounter('PriceCell') + +} + +export function StableMoveCell({ move }: { move: number }) @{ + useLifecycleCounter('StableMoveCell') + = 0 ? 'quote-up' : 'quote-down'}`}>{formatSigned(move)} +} + +export function UpMoveCell({ move }: { move: number }) @{ + useLifecycleCounter('UpMoveCell') + ▲ {formatSigned(move)} +} + +export function DownMoveCell({ move }: { move: number }) @{ + useLifecycleCounter('DownMoveCell') + ▼ {formatSigned(move)} +} + +export function PercentChangeCell({ value }: { value: number }) @{ + useLifecycleCounter('PercentChangeCell') + = 0 ? 'quote-up' : 'quote-down'}`}> + {value >= 0 ? '+' : ''}{value.toFixed(2)}% + +} + +export function SparklineCell({ values }: { values: ReadonlyArray }) @{ + useLifecycleCounter('SparklineCell') + const rising = (values.at(-1) ?? 0) >= (values[0] ?? 0) + const first = values[0] ?? 0 + const range = values.reduce( + (result, value) => ({ min: Math.min(result.min, value), max: Math.max(result.max, value) }), + { min: first, max: first }, + ) + const scale = range.max - range.min || 1 + const denominator = Math.max(1, values.length - 1) + const points = values.map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - range.min) / scale) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }).join(' ') + + + + +} + +const formatSigned = (value: number): string => `${value >= 0 ? '+' : ''}${value.toFixed(2)}` diff --git a/examples/octane/realtime-trading/src/table/table-config/trading-columns.tsrx b/examples/octane/realtime-trading/src/table/table-config/trading-columns.tsrx new file mode 100644 index 0000000000..5b88f96e53 --- /dev/null +++ b/examples/octane/realtime-trading/src/table/table-config/trading-columns.tsrx @@ -0,0 +1,110 @@ +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells.tsrx' +import type { ColumnDef, TableFeatures } from '@tanstack/octane-table' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingBenchmarkController } from '../../benchmark/trading-benchmark-controller' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { row: { original: MarketQuote } } +interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => unknown +} + +const compact = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function createTradingColumns( + controller: TradingBenchmarkController, + rendererMode: RendererMode, +): Array> { + const columns: Array = [ + { id: 'instrument', header: 'Instrument', columns: [ + { id: 'market', header: 'Market', size: 72, accessorFn: (row) => row.venue, cell: ({ row }) => recordCellRender('Market', row.original.venue) }, + { id: 'name', header: 'Name', size: 180, accessorFn: (row) => row.company, cell: ({ row }) => recordCellRender('Name', row.original.company) }, + { id: 'symbol', header: 'Symbol', size: 92, accessorFn: (row) => row.symbol, filterFn: 'includesString', cell: ({ row }) => recordCellRender('Symbol', row.original.symbol) }, + ] }, + { id: 'priceAndChange', header: 'Price & Change', columns: [ + { id: 'price', header: 'Price', size: 96, accessorFn: (row) => row.price, sortFn: 'basic', cell: ({ row }) => recordCellRender('Last', controller.actions.selectSymbol(row.original.symbol)} />) }, + { id: 'change', header: 'Chg', size: 94, accessorFn: getDayChange, cell: ({ row }) => recordCellRender('Change', ) }, + { id: 'changePercent', header: 'Chg%', size: 90, accessorFn: getDayChangePercent, cell: ({ row }) => recordCellRender('ChangePercent', ) }, + ] }, + { id: 'orderBook', header: 'Order Book', columns: [ + { id: 'bid', header: 'Bid', size: 90, accessorFn: (row) => row.bid, cell: ({ row }) => recordCellRender('Bid', row.original.bid.toFixed(2)) }, + { id: 'bidSize', header: 'Bid Vol', size: 100, accessorFn: (row) => row.bidSize, cell: ({ row }) => recordCellRender('BidVolume', compact.format(row.original.bidSize)) }, + { id: 'ask', header: 'Ask', size: 90, accessorFn: (row) => row.ask, cell: ({ row }) => recordCellRender('Ask', row.original.ask.toFixed(2)) }, + { id: 'askSize', header: 'Ask Vol', size: 100, accessorFn: (row) => row.askSize, cell: ({ row }) => recordCellRender('AskVolume', compact.format(row.original.askSize)) }, + ] }, + { id: 'session', header: 'Session', columns: [ + { id: 'open', header: 'Open', size: 90, accessorFn: (row) => row.open, cell: ({ row }) => recordCellRender('Open', row.original.open.toFixed(2)) }, + { id: 'high', header: 'High', size: 90, accessorFn: (row) => row.high, cell: ({ row }) => recordCellRender('High', row.original.high.toFixed(2)) }, + { id: 'low', header: 'Low', size: 90, accessorFn: (row) => row.low, cell: ({ row }) => recordCellRender('Low', row.original.low.toFixed(2)) }, + ] }, + { id: 'chart', header: 'Chart', columns: [ + { id: 'history', header: 'Intraday', size: 150, enableSorting: false, cell: ({ row }) => recordCellRender('Intraday', ) }, + ] }, + ] + return columns as unknown as Array> +} + +function MoveCell({ mode, move }: { mode: RendererMode; move: number }) @{ + if (mode === 'stable') return + if (move >= 0) return + +} + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max(rowModelDiagnostics.maxDurationMs, duration) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 !== 0) return rows + try { + performance.measure('tanstack-row-model', { start, end, detail: { rowCount: rows.length } }) + if (rowModelDiagnostics.calls % 20_000 === 0) performance.clearMeasures('tanstack-row-model') + } catch { /* optional User Timing detail */ } + return rows +} + +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 ? 0 : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/octane/realtime-trading/src/table/table-interactions.ts b/examples/octane/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..42356f5bd8 --- /dev/null +++ b/examples/octane/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,144 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Partial> +} + +export interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Partial> + } +} + +export interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export function findTradingGridCellTarget( + table: TradingGridTable, + path: Array, +): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row?.getAllCellsByColumnId()[columnId] + return row && cell ? { element: target, cell } : null + } + + return null +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/octane/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/octane/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..fecaf38f6f --- /dev/null +++ b/examples/octane/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,56 @@ +import { useLayoutEffect, useState } from 'octane' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, +} from '@tanstack/virtual-core' +import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' + +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} + +export function useVirtualizer< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: PartialKeys< + VirtualizerOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): Virtualizer { + const [, setRenderVersion] = useState(0) + const resolvedOptions: VirtualizerOptions = { + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + ...options, + onChange: (instance, sync) => { + setRenderVersion((version) => version + 1) + options.onChange?.(instance, sync) + }, + } + const [instance] = useState( + () => new Virtualizer(resolvedOptions), + ) + instance.setOptions(resolvedOptions) + useLayoutEffect(() => instance._didMount(), [instance]) + useLayoutEffect(() => instance._willUpdate(), null) + return instance +} diff --git a/examples/octane/realtime-trading/src/table/trading-table.ts b/examples/octane/realtime-trading/src/table/trading-table.ts new file mode 100644 index 0000000000..ff1d5af1b3 --- /dev/null +++ b/examples/octane/realtime-trading/src/table/trading-table.ts @@ -0,0 +1,9 @@ +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-columns.tsrx' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns.tsrx' +export type { VirtualScrollMode } from './trading-row-virtualizer' diff --git a/examples/octane/realtime-trading/src/table/use-trading-grid-pointer.ts b/examples/octane/realtime-trading/src/table/use-trading-grid-pointer.ts new file mode 100644 index 0000000000..671ad11f3f --- /dev/null +++ b/examples/octane/realtime-trading/src/table/use-trading-grid-pointer.ts @@ -0,0 +1,59 @@ +import { useMemo, useRef } from 'octane' +import { + findTradingGridCellTarget, + selectRowFromPointer, +} from './table-interactions' +import type { TradingGridTable } from './table-interactions' + +export interface TradingGridPointerHandlers { + readonly onMouseDown: (event: MouseEvent) => void + readonly onPointerOver: (event: PointerEvent) => void + readonly onMouseLeave: () => void + readonly onClick: (event: MouseEvent) => void +} + +export function useTradingGridPointer( + table: TradingGridTable, + selectSymbol: (symbol: string) => void, +): TradingGridPointerHandlers { + const lastPointerCell = useRef(null) + + return useMemo( + () => ({ + onMouseDown(event) { + if (event.button !== 0) return + + const target = findTradingGridCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + lastPointerCell.current = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)( + event, + ) + }, + onPointerOver(event) { + if ((event.buttons & 1) === 0) { + lastPointerCell.current = null + return + } + + const target = findTradingGridCellTarget(table, event.composedPath()) + if (!target || target.element === lastPointerCell.current) return + + lastPointerCell.current = target.element + target.cell.getSelectionExtendHandler()(event) + }, + onMouseLeave() { + lastPointerCell.current = null + }, + onClick(event) { + const target = findTradingGridCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + }, + }), + [selectSymbol, table], + ) +} diff --git a/examples/octane/realtime-trading/src/use-store-value.ts b/examples/octane/realtime-trading/src/use-store-value.ts new file mode 100644 index 0000000000..eda913025e --- /dev/null +++ b/examples/octane/realtime-trading/src/use-store-value.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'octane' + +interface SubscribableValue { + get: () => T + subscribe: (listener: (value: T) => void) => { unsubscribe: () => void } +} + +export function useStoreValue(source: SubscribableValue): T { + const [value, setValue] = useState(() => source.get()) + useEffect(() => { + setValue(source.get()) + const subscription = source.subscribe((nextValue) => setValue(nextValue)) + return () => subscription.unsubscribe() + }, [source]) + return value +} + +export function useStoreSelector( + source: SubscribableValue, + selector: (value: T) => TSelected, + compare: (previous: TSelected, next: TSelected) => boolean, +): TSelected { + const [selected, setSelected] = useState(() => selector(source.get())) + useEffect(() => { + const update = (value: T): void => { + const next = selector(value) + setSelected((previous) => (compare(previous, next) ? previous : next)) + } + + update(source.get()) + const subscription = source.subscribe(update) + return () => subscription.unsubscribe() + }, [compare, selector, source]) + return selected +} + +export function shallowEqual(previous: T, next: T): boolean { + if (Object.is(previous, next)) return true + if ( + typeof previous !== 'object' || + previous === null || + typeof next !== 'object' || + next === null + ) { + return false + } + + const previousRecord = previous as Record + const nextRecord = next as Record + const previousKeys = Object.keys(previousRecord) + const nextKeys = Object.keys(nextRecord) + return ( + previousKeys.length === nextKeys.length && + previousKeys.every((key) => Object.is(previousRecord[key], nextRecord[key])) + ) +} diff --git a/examples/octane/realtime-trading/src/vite-env.d.ts b/examples/octane/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/octane/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts b/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..44eae9d573 --- /dev/null +++ b/examples/octane/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Octane realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/octane/realtime-trading/tsconfig.json b/examples/octane/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..a33b01356c --- /dev/null +++ b/examples/octane/realtime-trading/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "octane", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/octane/realtime-trading/vite.config.ts b/examples/octane/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..c98944687d --- /dev/null +++ b/examples/octane/realtime-trading/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { octane } from 'octane/compiler/vite' + +export default defineConfig({ + server: { + port: 7786, + allowedHosts: true, + }, + plugins: [octane()], +}) diff --git a/examples/preact/realtime-trading/README.md b/examples/preact/realtime-trading/README.md new file mode 100644 index 0000000000..3f13cd3f31 --- /dev/null +++ b/examples/preact/realtime-trading/README.md @@ -0,0 +1,174 @@ +# Preact realtime trading benchmark + +This standalone example exercises the current TanStack Preact Table adapter +with an immutable high-frequency feed, table interactions, custom components, +virtual rows, and browser diagnostics. It is a rendering stress lab rather than +an exchange or network-latency simulation. + +## Run and verify + +```bash +pnpm --dir examples/preact/realtime-trading dev +``` + +Open `http://localhost:7780`. + +```bash +pnpm --dir examples/preact/realtime-trading test:types +pnpm --dir examples/preact/realtime-trading lint +pnpm --dir examples/preact/realtime-trading build +pnpm --dir examples/preact/realtime-trading test:e2e +``` + +Use the production build for measurements; development checks inflate the +absolute cost. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------- | ------------------------------------------------------------------------------------------------- | +| `src/feed/` | Market types, instruments, immutable update helpers, config, controller, and controller hook. | +| `src/feed/worker/` | Worker protocol, deterministic market engine, and module worker. | +| `src/benchmark/` | Feed/render timing, DOM/long-frame observers, row-model and component diagnostics. | +| `src/shell/` | TanStack Store contexts, viewport shell, metrics, controls, selected instrument, and diagnostics. | +| `src/table/table-config/` | Grouped columns, row/cell components, and render counters. | +| `src/table/` | Preact Table instance, subscriptions, interactions, column layout, and Virtual Core hook. | +| `src/App.tsx` | Creates the controllers and composes providers, shell, and table. | + +Feed state and benchmark state are different controllers. The feed does not +depend on the monitor; the benchmark subscribes to feed lifecycle callbacks. +TanStack Preact Store contexts pass stable controller objects, while components +subscribe to direct feed atoms. `quotes` has an independent high-frequency atom; +status, instrument count, workload, delivery, and chart settings each have their +own atom and cannot notify the table data boundary. Renderer mode and selected +symbol are also separate atoms so their updates do not invalidate unrelated +shell content. Benchmark metrics remain an aggregate snapshot store. + +## Feed and worker pipeline + +The initial setup is 100 instruments, 10K generated samples/s, 20 ms worker +delivery, enabled intraday charts, and 16 ms chart sampling. + +The module worker keeps mutable quote state private. A deterministic PRNG and a +16 ms budget loop generate samples; a `Map` coalesces repeated updates for the +same row. A separate publication timer sends the latest unique row updates at +the configured interval. The main thread creates a new outer array and new +objects only for changed rows, preserving every untouched row reference. +History arrays are replaced only when their sampling interval elapses. Session +IDs reject stale messages after reset or row-count changes. + +- **Synthetic quote workload** is worker-side generated samples per second. It + is not pointer events, renders, or messages. +- **Worker delivery interval** controls coalesced `postMessage` cadence; 20 ms + targets about 50 messages/s. +- **Changed rows** counts row snapshots applied on the main thread. Rows are + deduplicated within one snapshot, but the same row can be counted again in a + later snapshot, so it is throughput rather than a global distinct-row count. +- **Message samples** shows how much generated work the latest message contains. + +The 25K burst deliberately creates and flushes one expensive batch. The worker +mimics an external streaming producer, but network transport is not measured. + +## Table and interaction architecture + +The 14 leaf columns are grouped into Instrument, Price & Change, Order Book, +Session, and Chart. The example supports core sorting/filtering, on-change +resizing, double-click size reset, drag column ordering, row selection, drag +cell ranges, keyboard navigation, and custom Price/Move/Percent/Sparkline cells. +Stable instrument IDs are supplied through `getRowId`. + +`table.Subscribe` boundaries isolate table state, resize handles, and row +selection. `TradingGridPointerController` is allocated once for the body and +receives delegated mouse/pointer events. It resolves cells from +`event.composedPath()` and data attributes, eliminating listeners per cell. +Row hover is CSS-only. + +Column widths are table-level CSS custom properties updated only when sizing or +ordering changes. Initial sizes expand to the viewport through a +`ResizeObserver`; manual resizing turns off further auto-fit. The optional move +component A/B mode intentionally destroys/recreates component types and should +not be treated as the normal baseline. + +## Virtualization + +The preference is `auto`, `tanstack`, or `none`: + +- below 200 rows, `auto` means Full DOM, while Virtual remains selectable; +- 200–1,499 rows default to TanStack Virtual but can be switched to Full DOM; +- 1,500 or more rows force Virtual and lock the control. + +Because this adapter consumes `@tanstack/virtual-core` directly, the local +`useVirtualizer` hook owns the Virtualizer instance and synchronizes its change +notifications with Preact. It uses 32 px rows, 10-row overscan, row IDs as item +keys, transformed rows, and a spacer body. The footer reads the virtualizer +range. Both modes use `content-visibility: auto`; that browser hint does not +avoid mounting every component in Full DOM mode. + +## Performance decisions + +- worker-side generation and pre-message coalescing; +- structural sharing for unchanged rows and histories; +- stable table/virtualizer keys; +- direct Preact Store atom subscriptions, including an isolated quote atom; +- table/row/resize subscription boundaries; +- one delegated grid interaction controller; +- CSS variables for column sizing; +- opt-in component churn and independently sampled sparklines; +- virtual mounting for large row counts; +- benchmark publication slower than the data stream. + +The immutable outer array must change when a batch is applied. Stable inner +references reduce renderer work, but sorting/filtering may still require a core +row-model pass when data changes. + +## Diagnostics and interpretation + +The compact **Live health** block lives in the configurator sidebar and shows +four cross-framework signals: + +- **Frame rate (est.)** counts `requestAnimationFrame` callbacks over a rolling + one-second window. It is capped by display refresh and is not a measurement of + GPU-presented frames. +- **Average commit latency** is a rolling three-second average from the first + pending market mutation to the table's DOM commit. It includes scheduling, + Preact work, and the commit; it is not component render duration. +- **Long frames** comes from the Long Animation Frames API and is cumulative + since reset, with the worst observed duration when supported. +- **Throughput** pairs changed rows/s with applied snapshots/s; changed rows are + deduplicated per snapshot, not across the complete reporting window. + +Detailed diagnostics retain worker samples, messages, state applies, table DOM +commits, a 10-second commit-latency p95/max, cumulative slow commits, mounted +hosts, component creation/destruction, cell callbacks by column, component +executions by type, DOM `MutationRecord` rate, row-model timing, and heap data +where supported. + +The in-memory latency and row-model aggregates inspect every call, while the +Performance timeline writes only one User Timing measure per 20 calls to keep +instrumentation from dominating a hot run. The `MutationObserver` watches the +table body for text/children and only `class`/`style` attribute changes. Its +rate counts browser records rather than DOM operations: records can be +coalesced, equivalent UIs can produce different patterns, and creating records +has real overhead. Heap is Chrome-only `usedJSHeapSize`, is sensitive to +garbage-collection timing, and should be read as a trend rather than proof of a +leak. + +The monitor itself adds one lightweight `requestAnimationFrame` callback and +publishes the sidebar snapshot every 500 ms. That subscription is independent +from the quote atom and table data boundary, but it remains instrumentation +overhead and must stay enabled on both sides of an A/B comparison. + +Callback execution does not imply a DOM mutation, and temporary heap growth is +not by itself a leak. Compare identical production configurations and confirm +retention with post-GC heap snapshots. Use browser Performance tooling and +Preact DevTools for call stacks alongside the in-app counters. + +## Standalone policy + +All feed, worker, instrument, shell, style, and benchmark files are copied into +this directory intentionally. The example can run alone or be moved to +StackBlitz without a shared example package, so common implementation and README +sections are duplicated across adapters by design. + +The workspace resolves the pinned `@tanstack/preact-table` dependency to the +repository package while keeping the example manifest release-like. diff --git a/examples/preact/realtime-trading/index.html b/examples/preact/realtime-trading/index.html new file mode 100644 index 0000000000..849dbe8b48 --- /dev/null +++ b/examples/preact/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + + + + Preact Real-time Trading FlexRender Lab + + +
+ + + diff --git a/examples/preact/realtime-trading/package.json b/examples/preact/realtime-trading/package.json new file mode 100644 index 0000000000..4a6e7421e3 --- /dev/null +++ b/examples/preact/realtime-trading/package.json @@ -0,0 +1,25 @@ +{ + "name": "tanstack-preact-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/preact-store": "^0.13.1", + "@tanstack/preact-table": "9.1.2", + "@tanstack/virtual-core": "^3.17.7", + "preact": "^10.29.7" + }, + "devDependencies": { + "@preact/preset-vite": "^2.10.6", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/preact/realtime-trading/src/App.tsx b/examples/preact/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..3b702068d6 --- /dev/null +++ b/examples/preact/realtime-trading/src/App.tsx @@ -0,0 +1,21 @@ +import { useMarketFeedController } from './feed/use-market-feed-controller' +import { useTradingBenchmarkController } from './benchmark/use-trading-benchmark-controller' +import { TradingShell } from './shell/TradingShell' +import { TradingShellProvider } from './shell/trading-shell-context' +import { TradingTable } from './table/trading-table' + +export function App() { + const feed = useMarketFeedController() + const controller = useTradingBenchmarkController(feed) + return ( + + + + + + ) +} + +function TradingTableOutlet() { + return +} diff --git a/examples/preact/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/preact/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..f072634bbc --- /dev/null +++ b/examples/preact/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,440 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + estimatedFrameRate: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + estimatedFrameRate: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { + entryCount: 0, + measureCallsByName: {} as Record, +} + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + const measureCall = (userTiming.measureCallsByName[name] ?? 0) + 1 + userTiming.measureCallsByName[name] = measureCall + if ((measureCall - 1) % 20 !== 0) return + + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sessionStartedAt: performance.now(), + sampleStartedAt: performance.now(), + pendingCommitStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markCommitPending(): void { + this.#runtime.pendingCommitStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingCommitStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingCommitStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingCommitStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingCommitStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + estimatedFrameRate: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sessionStartedAt = performance.now() + runtime.sampleStartedAt = runtime.sessionStartedAt + runtime.pendingCommitStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/preact/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/preact/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..1844541924 --- /dev/null +++ b/examples/preact/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/preact-store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/preact/realtime-trading/src/benchmark/use-table-benchmark.ts b/examples/preact/realtime-trading/src/benchmark/use-table-benchmark.ts new file mode 100644 index 0000000000..de40f98f9f --- /dev/null +++ b/examples/preact/realtime-trading/src/benchmark/use-table-benchmark.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'preact/hooks' +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function useTableBenchmark( + controller: TradingBenchmarkController, +): void { + useEffect(() => { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) { + return + } + + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() + }, [controller]) +} diff --git a/examples/preact/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts b/examples/preact/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts new file mode 100644 index 0000000000..670253ed6a --- /dev/null +++ b/examples/preact/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'preact/hooks' +import { TradingBenchmarkController } from './trading-benchmark-controller' +import type { MarketFeedController } from '../feed/market-feed-controller' + +export function useTradingBenchmarkController(feed: MarketFeedController) { + const [controller] = useState(() => new TradingBenchmarkController(feed)) + + useEffect(() => controller.start(), [controller]) + + return controller +} + +export type { TradingBenchmarkController } from './trading-benchmark-controller' diff --git a/examples/preact/realtime-trading/src/feed/feed-sample-rates.ts b/examples/preact/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/preact/realtime-trading/src/feed/market-data.ts b/examples/preact/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/preact/realtime-trading/src/feed/market-feed-config.ts b/examples/preact/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/preact/realtime-trading/src/feed/market-feed-controller.ts b/examples/preact/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..df732bfbfb --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/preact-store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/preact/realtime-trading/src/feed/market-instruments.ts b/examples/preact/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/preact/realtime-trading/src/feed/use-market-feed-controller.ts b/examples/preact/realtime-trading/src/feed/use-market-feed-controller.ts new file mode 100644 index 0000000000..29196b8773 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/use-market-feed-controller.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from 'preact/hooks' +import { MarketFeedController } from './market-feed-controller' + +export function useMarketFeedController(): MarketFeedController { + const [controller] = useState(() => new MarketFeedController()) + + useEffect(() => controller.start(), [controller]) + + return controller +} diff --git a/examples/preact/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/preact/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/preact/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/preact/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/preact/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/preact/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/preact/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/preact/realtime-trading/src/index.css b/examples/preact/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/preact/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/preact/realtime-trading/src/main.tsx b/examples/preact/realtime-trading/src/main.tsx new file mode 100644 index 0000000000..ff31652405 --- /dev/null +++ b/examples/preact/realtime-trading/src/main.tsx @@ -0,0 +1,10 @@ +import { render } from 'preact' +import { App } from './App' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +// StrictMode is intentionally omitted: its development-only mount replay would +// contaminate the component lifecycle counters used by this benchmark. +render(, rootElement) diff --git a/examples/preact/realtime-trading/src/shell/TradingShell.tsx b/examples/preact/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..d3a22399ee --- /dev/null +++ b/examples/preact/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,651 @@ +import { shallow, useSelector } from '@tanstack/preact-store' +import { useState } from 'preact/hooks' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { + useMarketFeedController, + useTradingShellController, + useTradingShellState, +} from './trading-shell-context' +import { configuratorOptions } from './configurator-options' +import type { ComponentChildren } from 'preact' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export function TradingShell(props: { children: ComponentChildren }) { + const [sidebarOpen, setSidebarOpen] = useState(true) + const toggleSidebar = () => setSidebarOpen((open) => !open) + + return ( +
+
+ + + {import.meta.env.DEV && ( + + )} +
+ +
+ {props.children} +
+ +
{sidebarOpen && }
+
+ ) +} + +function AppHeader(props: { + sidebarOpen: boolean + onSidebarToggle: () => void +}) { + const feed = useMarketFeedController() + const workerReady = useSelector(feed.workerReady) + const running = useSelector(feed.running) + return ( +
+
+ MARKET MONITOR +
+
+ + + +
+
+ ) +} + +function MarketStatusbar() { + const { lastBatchSize, lastUpdateCount, mountedCells, liveComponents } = + useTradingShellState( + (state) => ({ + lastBatchSize: state.metrics.lastBatchSize, + lastUpdateCount: state.metrics.lastUpdateCount, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + }), + { compare: shallow }, + ) + return ( +
+ + MESSAGE SAMPLES {formatInteger(lastBatchSize)} + + + CHANGED ROWS {formatInteger(lastUpdateCount)} + + + HOSTS {formatInteger(mountedCells)} + + + COMPONENTS {formatInteger(liveComponents)} + +
+ ) +} + +function Configurator() { + const benchmarkState = useTradingShellState( + (storeState) => ({ + requestedVirtualScrollMode: storeState.requestedVirtualScrollMode, + }), + { compare: shallow }, + ) + const controller = useTradingShellController() + const feed = useMarketFeedController() + const running = useSelector(feed.running) + const instrumentCount = useSelector(feed.instrumentCount) + const targetTicksPerSecond = useSelector(feed.targetTicksPerSecond) + const publishIntervalMs = useSelector(feed.publishIntervalMs) + const updateSparklines = useSelector(feed.updateSparklines) + const sparklineSampleIntervalMs = useSelector(feed.sparklineSampleIntervalMs) + const rendererMode = useSelector(controller.renderAtoms.rendererMode) + const { actions } = controller + const feedActions = feed.actions + const { requestedVirtualScrollMode } = benchmarkState + const { setRendererMode, setVirtualScrollEnabled, resetMarket } = actions + const { + toggle, + setInstrumentCount, + setTargetRate, + setPublishInterval, + setSparklineUpdates, + setSparklineSampleInterval, + runBurst, + } = feedActions + const virtualScrollForced = instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT + const virtualScrollMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + + return ( + + ) +} + +function LiveHealth() { + const { metrics, longAnimationFramesSupported } = useTradingShellState( + (state) => ({ + metrics: state.metrics, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + return ( +
+

LIVE HEALTH

+
+ FRAME RATE (EST.) + + {metrics.estimatedFrameRate.toFixed(1)} + + rAF callbacks/s · rolling 1 s +
+
+ AVG COMMIT + + {formatMs(metrics.averageCommitLatencyMs)} + + snapshot → DOM · rolling 3 s +
+
+ LONG FRAMES + {longAnimationFramesSupported ? ( + <> + 0 ? 'metric-alert' : ''} + > + {metrics.longAnimationFrames} + + + since reset · worst {formatMs(metrics.worstLongAnimationFrameMs)} + + + ) : ( + <> + N/A + unsupported + + )} +
+
+ THROUGHPUT + + {formatRate(metrics.rowUpdatesPerSecond)} rows/s + + + {metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows + deduplicated per snapshot + +
+
+ ) +} + +function Diagnostics() { + const { + metrics, + mountedCells, + liveComponents, + longAnimationFramesSupported, + } = useTradingShellState( + (state) => ({ + metrics: state.metrics, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + return ( +
+

DIAGNOSTICS

+
+
+
Worker samples / s
+
+ {formatRate(metrics.actualTicksPerSecond)} +
+
+
+
Changed rows / s
+
+ {formatRate(metrics.rowUpdatesPerSecond)} +
+
+
+
Worker messages / s
+
+ {metrics.workerMessagesPerSecond.toFixed(1)} +
+
+
+
State applies / s
+
+ {metrics.stateApplicationsPerSecond.toFixed(1)} +
+
+
+
Table DOM commits / s
+
+ {metrics.tableCommitsPerSecond.toFixed(1)} +
+
+
+
Commit latency p95 / max
+
+ {formatMs(metrics.p95CommitLatencyMs)} /{' '} + {formatMs(metrics.maxCommitLatencyMs)} +
+
+
+
Mounted cells
+
{formatInteger(mountedCells)}
+
+
+
Live components
+
{formatInteger(liveComponents)}
+
+
+
Created / destroyed
+
+ {formatInteger(metrics.componentsCreated)} /{' '} + {formatInteger(metrics.componentsDestroyed)} +
+
+
+
Renderer callbacks / s
+
+ {formatRate(metrics.cellRendererCallsPerSecond)} +
+
+
+
Component executions / s
+
+ {formatRate(metrics.componentRenderCallsPerSecond)} +
+
+
+
Executions by component / s
+
+ {formatInvocationRates(metrics.componentRenderRates)} +
+
+
+
Callbacks by column / s
+
+ {formatInvocationRates(metrics.cellRendererRates)} +
+
+
+
Observed MutationRecords / s
+
+ {formatRate(metrics.domMutationsPerSecond)} +
+
+
+
Core row model calls / s
+
+ {metrics.rowModelCallsPerSecond.toFixed(1)} +
+
+
+
Core row model avg / max
+
+ {formatMs(metrics.rowModelAverageMs)} /{' '} + {formatMs(metrics.rowModelMaxMs)} +
+
+
+
Visible rows
+
+ {formatInteger(metrics.visibleRows)} +
+
+
+
Worker messages
+
+ {formatInteger(metrics.workerMessages)} +
+
+
+
Worker-coalesced updates / s
+
+ {formatRate(metrics.supersededUpdatesPerSecond)} +
+
+
+
Last message samples / updated rows
+
+ {formatInteger(metrics.lastBatchSize)} /{' '} + {formatInteger(metrics.lastUpdateCount)} +
+
+
+
Commits > 16.7 ms since reset
+
{metrics.slowCommits}
+
+
+
Long animation frames
+
+ {longAnimationFramesSupported + ? formatInteger(metrics.longAnimationFrames) + : 'Unsupported'} +
+
+
+
+ JS heap (GC-sensitive) +
+
+ {metrics.heapMb === null + ? 'N/A' + : `${metrics.heapMb.toFixed(1)} MB`} +
+
+
+
+ ) +} + +function SelectedInstrument() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const selectedSymbol = useSelector(controller.renderAtoms.selectedSymbol) + const quotes = useSelector(feed.quotes) + const selectedQuote = feed.getQuoteBySymbol(quotes, selectedSymbol) + return ( +
+

SELECTED INSTRUMENT

+ {selectedQuote ? ( + <> +
+
+ {selectedQuote.symbol} + {selectedQuote.company} +
+ {selectedQuote.venue} +
+
+
+
Last
+
{selectedQuote.price.toFixed(2)}
+
+
+
Bid / ask
+
+ {selectedQuote.bid.toFixed(2)} / {selectedQuote.ask.toFixed(2)} +
+
+
+ + ) : ( +

+ Click or begin a cell selection in any row to inspect its instrument. +

+ )} +
+ ) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['componentRenderRates'], +): string { + const activeRates = rates + .filter((rate) => rate.callsPerSecond > 0) + .sort((left, right) => right.callsPerSecond - left.callsPerSecond) + return activeRates.length === 0 + ? 'No calls in sample' + : activeRates + .map((rate) => `${rate.name} ${formatRate(rate.callsPerSecond)}`) + .join(' · ') +} diff --git a/examples/preact/realtime-trading/src/shell/configurator-options.ts b/examples/preact/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/preact/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/preact/realtime-trading/src/shell/trading-shell-context.tsx b/examples/preact/realtime-trading/src/shell/trading-shell-context.tsx new file mode 100644 index 0000000000..8ad0654958 --- /dev/null +++ b/examples/preact/realtime-trading/src/shell/trading-shell-context.tsx @@ -0,0 +1,50 @@ +import { createStoreContext, useSelector } from '@tanstack/preact-store' +import type { ComponentChildren } from 'preact' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/use-trading-benchmark-controller' +import type { TradingBenchmarkState } from '../benchmark/trading-benchmark-controller' +import type { UseSelectorOptions } from '@tanstack/preact-store' + +const { + StoreProvider: TradingStoreProvider, + useStoreContext: useTradingShellController, +} = createStoreContext() + +const { + StoreProvider: MarketFeedStoreProvider, + useStoreContext: useMarketFeedController, +} = createStoreContext() + +export function MarketFeedProvider(props: { + controller: MarketFeedController + children: ComponentChildren +}) { + return ( + + {props.children} + + ) +} + +export function TradingShellProvider(props: { + controller: TradingBenchmarkController + children: ComponentChildren +}) { + return ( + + + {props.children} + + + ) +} + +export { useMarketFeedController, useTradingShellController } + +export function useTradingShellState( + selector: (state: TradingBenchmarkState) => TSelected, + options?: UseSelectorOptions, +): TSelected { + const controller = useTradingShellController() + return useSelector(controller.store, selector, options) +} diff --git a/examples/preact/realtime-trading/src/table/table-config/quote-cells.tsx b/examples/preact/realtime-trading/src/table/table-config/quote-cells.tsx new file mode 100644 index 0000000000..05c16e9578 --- /dev/null +++ b/examples/preact/realtime-trading/src/table/table-config/quote-cells.tsx @@ -0,0 +1,219 @@ +import { useEffect } from 'preact/hooks' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + 'use no memo' + // Benchmark instrumentation is intentionally impure and must run per call. + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +function useLifecycleCounter(componentName: QuoteComponentName): void { + 'use no memo' + // This diagnostic mutation measures component-function invocation itself. + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + useEffect(() => { + quoteCellLifecycle.created++ + return () => { + quoteCellLifecycle.destroyed++ + } + }, []) +} + +export function PriceCell(props: { + price: number + move: number + onSelect: () => void +}) { + useLifecycleCounter('PriceCell') + return ( + + ) +} + +export function StableMoveCell({ move }: { move: number }) { + useLifecycleCounter('StableMoveCell') + return ( + = 0 ? 'quote-up' : 'quote-down'}`}> + {formatSigned(move)} + + ) +} + +export function UpMoveCell({ move }: { move: number }) { + useLifecycleCounter('UpMoveCell') + return ▲ {formatSigned(move)} +} + +export function DownMoveCell({ move }: { move: number }) { + useLifecycleCounter('DownMoveCell') + return ▼ {formatSigned(move)} +} + +export function PercentChangeCell({ value }: { value: number }) { + useLifecycleCounter('PercentChangeCell') + return ( + = 0 ? 'quote-up' : 'quote-down'}`} + > + {value >= 0 ? '+' : ''} + {value.toFixed(2)}% + + ) +} + +export function SpreadCell({ bid, ask }: { bid: number; ask: number }) { + useLifecycleCounter('SpreadCell') + const spread = Math.max(0, ask - bid) + const midpoint = (bid + ask) / 2 + const basisPoints = midpoint === 0 ? 0 : (spread / midpoint) * 10_000 + + return ( + = 4 ? 'spread-wide' : ''}`}> + {spread.toFixed(2)} + {basisPoints.toFixed(1)} bp + + ) +} + +export function DepthCell(props: { bidSize: number; askSize: number }) { + useLifecycleCounter('DepthCell') + const total = props.bidSize + props.askSize + const bidShare = total === 0 ? 50 : (props.bidSize / total) * 100 + + return ( +
+ + + + {compactNumber.format(props.bidSize)} + {compactNumber.format(props.askSize)} + +
+ ) +} + +export function QuoteAgeCell({ ageMs }: { ageMs: number }) { + useLifecycleCounter('QuoteAgeCell') + const className = + ageMs >= 1_500 + ? 'quote-age quote-age-stale' + : ageMs >= 500 + ? 'quote-age quote-age-warm' + : 'quote-age' + + return ( + + {ageMs < 1_000 + ? `${Math.round(ageMs)} ms` + : `${(ageMs / 1_000).toFixed(1)} s`} + + ) +} + +export function SparklineCell({ values }: { values: ReadonlyArray }) { + useLifecycleCounter('SparklineCell') + const rising = (values.at(-1) ?? 0) >= (values[0] ?? 0) + const { min, max } = findRange(values) + const range = max - min || 1 + const denominator = Math.max(1, values.length - 1) + const points = values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + + return ( + + + + ) +} + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} + +function findRange(values: ReadonlyArray): { + min: number + max: number +} { + const first = values[0] ?? 0 + return values.reduce( + (range, value) => { + range.min = Math.min(range.min, value) + range.max = Math.max(range.max, value) + return range + }, + { min: first, max: first }, + ) +} diff --git a/examples/preact/realtime-trading/src/table/table-config/trading-table-config.tsx b/examples/preact/realtime-trading/src/table/table-config/trading-table-config.tsx new file mode 100644 index 0000000000..7e1e1a6fbd --- /dev/null +++ b/examples/preact/realtime-trading/src/table/table-config/trading-table-config.tsx @@ -0,0 +1,301 @@ +import { useSelector } from '@tanstack/preact-store' +import { useTradingShellController } from '../../shell/trading-shell-context' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import type { ComponentChildren } from 'preact' +import type { MarketQuote } from '../../feed/market-data' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +export interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => ComponentChildren +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export const tradingColumns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender('Last', ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: (row) => getDayChange(row), + cell: ({ row }) => + recordCellRender('Change', ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: (row) => getDayChangePercent(row), + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + , + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender( + 'BidVolume', + compactFormatter.format(row.original.bidSize), + ), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender( + 'AskVolume', + compactFormatter.format(row.original.askSize), + ), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender( + 'Intraday', + , + ), + }, + ], + }, +] + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} + +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + + if (rowModelDiagnostics.calls % 1_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + if ((rowModelDiagnostics.calls - 1) % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } + } + + return rows +} + +function LastPriceCell(props: { quote: MarketQuote }) { + const { selectSymbol } = useTradingShellController().actions + return ( + selectSymbol(props.quote.symbol)} + /> + ) +} + +function DayChangeCell(props: { quote: MarketQuote }) { + const { rendererMode } = useTradingShellController().renderAtoms + const mode = useSelector(rendererMode) + const change = getDayChange(props.quote) + if (mode === 'stable') { + return + } + return change >= 0 ? ( + + ) : ( + + ) +} + +export function TradingRow(props: { + quote: MarketQuote + children: ComponentChildren + rowSelected: boolean + virtualRow?: { index: number; start: number } +}) { + const { selectedSymbol } = useTradingShellController().renderAtoms + const selected = useSelector( + selectedSymbol, + (symbol) => symbol === props.quote.symbol, + ) + return ( + + {props.children} + + ) +} + +function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/preact/realtime-trading/src/table/table-interactions.ts b/examples/preact/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..5b20a5b795 --- /dev/null +++ b/examples/preact/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,190 @@ +import type { JSX } from 'preact' + +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + nativeEvent: event, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: JSX.TargetedKeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): JSX.AriaAttributes['aria-sort'] { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/preact/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/preact/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..2252ee2a35 --- /dev/null +++ b/examples/preact/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,58 @@ +import { useLayoutEffect, useState } from 'preact/hooks' +import { + Virtualizer, + elementScroll, + observeElementOffset, + observeElementRect, +} from '@tanstack/virtual-core' +import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' + +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} + +export function useVirtualizer< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: PartialKeys< + VirtualizerOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): Virtualizer { + const [, setRenderVersion] = useState(0) + const resolvedOptions: VirtualizerOptions = { + observeElementRect, + observeElementOffset, + scrollToFn: elementScroll, + ...options, + onChange: (instance, sync) => { + setRenderVersion((version) => version + 1) + options.onChange?.(instance, sync) + }, + } + const [instance] = useState( + () => new Virtualizer(resolvedOptions), + ) + + instance.setOptions(resolvedOptions) + useLayoutEffect(() => instance._didMount(), [instance]) + useLayoutEffect(() => instance._willUpdate()) + + return instance +} diff --git a/examples/preact/realtime-trading/src/table/trading-table.tsx b/examples/preact/realtime-trading/src/table/trading-table.tsx new file mode 100644 index 0000000000..6d96f2b23e --- /dev/null +++ b/examples/preact/realtime-trading/src/table/trading-table.tsx @@ -0,0 +1,614 @@ +import { + FlexRender, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, + useTable, +} from '@tanstack/preact-table' +import { useSelector } from '@tanstack/preact-store' +import { useLayoutEffect, useRef, useState } from 'preact/hooks' +import { useTableBenchmark } from '../benchmark/use-table-benchmark' +import { + useMarketFeedController, + useTradingShellController, + useTradingShellState, +} from '../shell/trading-shell-context' +import { + TradingRow, + readMeasuredRows, + tradingColumns, +} from './table-config/trading-table-config' +import { + TradingGridPointerController, + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from './table-interactions' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, + useVirtualizer, +} from './trading-row-virtualizer' +import type { VirtualScrollMode } from './trading-row-virtualizer' +import type { + CellSelectionBounds, + CellSelectionState, +} from '@tanstack/preact-table' +import type { VirtualItem } from '@tanstack/virtual-core' +import type { MarketQuote } from '../feed/market-data' +import type { CoreTableState } from './table-config/trading-table-config' + +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-table-config' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-table-config' +export type { VirtualScrollMode } from './trading-row-virtualizer' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) + +export function TradingTable() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const quotes = useSelector(feed.quotes) + const requestedVirtualScrollMode = useTradingShellState( + (state) => state.requestedVirtualScrollMode, + ) + const instrumentCount = useSelector(feed.instrumentCount) + const virtualScrollMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + + useLayoutEffect(() => feed.completeRender()) + useTableBenchmark(controller) + const table = useTradingTable({ quotes }) + const layoutRefs = useTradingTableLayout(table) + + return ( + ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnOrder: state.columnOrder, + })} + > + {(coreState) => ( + + )} + + ) +} + +type TradingTableInstance = ReturnType + +interface ColumnDragRuntime { + columnId: string | null + sourceElement: HTMLTableCellElement | null + targetElement: HTMLTableCellElement | null +} + +function clearColumnDrag(runtime: ColumnDragRuntime): void { + runtime.sourceElement?.classList.remove('is-column-dragging') + runtime.targetElement?.classList.remove('is-column-drop-target') + runtime.columnId = null + runtime.sourceElement = null + runtime.targetElement = null +} + +function showColumnDropTarget( + runtime: ColumnDragRuntime, + targetColumnId: string, + targetElement: HTMLTableCellElement | null, +): void { + runtime.targetElement?.classList.remove('is-column-drop-target') + runtime.targetElement = null + if (runtime.columnId === targetColumnId || !targetElement) return + targetElement.classList.add('is-column-drop-target') + runtime.targetElement = targetElement +} + +function useTradingTableLayout(table: TradingTableInstance) { + const scrollRef = useRef(null) + const tableRef = useRef(null) + const fitRuntime = useRef({ manuallyResized: false }) + const tableRuntime = useRef(table) + tableRuntime.current = table + + useLayoutEffect(() => { + const writeColumnSizes = () => { + const currentTable = tableRuntime.current + const tableElement = tableRef.current + if (!tableElement) return + for (const header of currentTable.getFlatHeaders()) { + tableElement.style.setProperty( + `--header-${header.id}-size`, + String(header.getSize()), + ) + tableElement.style.setProperty( + `--col-${header.column.id}-size`, + String(header.column.getSize()), + ) + } + tableElement.style.width = `${currentTable.getTotalSize()}px` + } + + writeColumnSizes() + const sizingSubscription = + tableRuntime.current.atoms.columnSizing.subscribe(writeColumnSizes) + const orderSubscription = + tableRuntime.current.atoms.columnOrder.subscribe(writeColumnSizes) + const fitAvailableWidth = () => { + const currentTable = tableRuntime.current + const scrollElement = scrollRef.current + if (!scrollElement || fitRuntime.current.manuallyResized) return + const currentWidth = currentTable.getTotalSize() + const availableWidth = scrollElement.clientWidth + if (availableWidth <= currentWidth + 1 || currentWidth <= 0) return + + const ratio = availableWidth / currentWidth + currentTable.setColumnSizing( + Object.fromEntries( + currentTable + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + const resizeObserver = new ResizeObserver(fitAvailableWidth) + const resizingSubscription = + tableRuntime.current.atoms.columnResizing.subscribe((state) => { + if (state.isResizingColumn !== false) { + fitRuntime.current.manuallyResized = true + } + }) + if (scrollRef.current) resizeObserver.observe(scrollRef.current) + fitAvailableWidth() + + return () => { + sizingSubscription.unsubscribe() + orderSubscription.unsubscribe() + resizingSubscription.unsubscribe() + resizeObserver.disconnect() + } + }, []) + + return { scrollRef, tableRef } +} + +function TradingTableHeader(props: { table: TradingTableInstance }) { + const dragRuntime = useRef({ + columnId: null, + sourceElement: null, + targetElement: null, + }) + + return ( + ({ + columnOrder: state.columnOrder, + sorting: state.sorting, + })} + > + {() => ( + + {props.table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isLeaf = header.subHeaders.length === 0 + const sorted = header.column.getIsSorted() + return ( + + {!header.isPlaceholder && + (isLeaf ? ( + <> +
{ + event.preventDefault() + showColumnDropTarget( + dragRuntime.current, + header.column.id, + event.currentTarget.closest('th'), + ) + }} + onDrop={(event) => { + event.preventDefault() + const dataTransfer = event.dataTransfer + const sourceId = + dataTransfer?.getData('text/plain') || + dragRuntime.current.columnId + if (sourceId) { + props.table.setColumnOrder( + reorderColumnIds( + props.table + .getVisibleLeafColumns() + .map((column) => column.id), + sourceId, + header.column.id, + ), + ) + } + clearColumnDrag(dragRuntime.current) + }} + > + + +
+ {header.column.getCanResize() && ( + + state.columnResizing.isResizingColumn === + header.column.id + } + > + {(isResizing) => ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + /> + )} + + )} + + ) : ( + + ))} + + ) + })} + + ))} + + )} + + ) +} + +function useTradingTable(props: { quotes: Array }) { + return useTable( + { + key: 'preact-realtime-trading', + features, + columns: tradingColumns, + data: props.quotes, + getRowId: (row) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }, + () => null, + ) +} + +function TradingTableViewport(props: { + table: ReturnType + rows: ReturnType['getRowModel']>['rows'] + sourceRowCount: number + layoutRefs: ReturnType + virtualScrollMode: VirtualScrollMode + reportRenderedRowCount: (count: number) => void +}) { + const rowVirtualizer = useVirtualizer({ + count: props.rows.length, + estimateSize: () => TRADING_ROW_HEIGHT, + getScrollElement: () => props.layoutRefs.scrollRef.current, + getItemKey: (index) => props.rows[index]?.id ?? index, + overscan: TRADING_ROW_OVERSCAN, + enabled: props.virtualScrollMode === 'tanstack', + }) + const virtualRows = rowVirtualizer.getVirtualItems() + const renderedRowCount = + props.virtualScrollMode === 'tanstack' + ? virtualRows.length + : props.rows.length + const visibleRange = readVisibleRange( + rowVirtualizer.range, + props.rows.length, + props.virtualScrollMode, + ) + + useLayoutEffect(() => { + props.reportRenderedRowCount(renderedRowCount) + }, [props.reportRenderedRowCount, renderedRowCount]) + + return ( + <> +
handleCellNavigation(props.table, event)} + > + + + +
+
+ {props.virtualScrollMode === 'tanstack' && ( +
+ + TanStack · Total · {props.rows.length} rows ·{' '} + {props.table.getVisibleLeafColumns().length} columns + + + {visibleRange + ? `Current · rows ${visibleRange.start}..${visibleRange.end}` + : 'Current · rows —'} + +
+ )} + + ) +} + +function TradingRows(props: { + table: ReturnType + rows: ReturnType['getRowModel']>['rows'] + sourceRowCount: number + virtualRows: Array + virtualScrollMode: VirtualScrollMode +}) { + const { selectSymbol } = useTradingShellController().actions + const [pointerInteractions] = useState( + () => new TradingGridPointerController(), + ) + + return ( + + pointerInteractions.handleMouseDown(props.table, event, selectSymbol) + } + onPointerOver={(event) => + pointerInteractions.handlePointerOver(props.table, event) + } + onMouseLeave={() => pointerInteractions.resetPointerCell()} + onClick={(event) => pointerInteractions.handleClick(props.table, event)} + > + {props.virtualScrollMode === 'tanstack' + ? props.virtualRows.map((virtualRow) => { + const row = props.rows[virtualRow.index] + return ( + + ) + }) + : props.rows.map((row) => ( + + ))} + + ) +} + +type TradingTableRow = ReturnType< + ReturnType['getRowModel'] +>['rows'][number] + +function TradingRowBoundary(props: { + table: ReturnType + row: TradingTableRow + virtualRow?: VirtualItem +}) { + const { row, table, virtualRow } = props + + return ( + + `${row.id in state.rowSelection ? 1 : 0}:${cellSelectionRowKey( + state.cellSelection, + table.getCellSelectionBounds(), + row.getDisplayIndex(), + row.id, + )}` + } + > + {() => ( + + {row.getVisibleCells().map((cell) => { + const edges = cell.getSelectionEdges() + + return ( + + + + ) + })} + + )} + + ) +} + +function getHeaderClassName(header: { + subHeaders: ReadonlyArray + column: { id: string } +}): string | undefined { + if (header.subHeaders.length > 0) return 'column-group-header' + return isTextColumn(header.column.id) ? undefined : 'numeric-header' +} + +function isTextColumn(columnId: string): boolean { + return columnId === 'market' || columnId === 'name' || columnId === 'symbol' +} + +function readVisibleRange( + range: { startIndex: number; endIndex: number } | null, + rowCount: number, + virtualScrollMode: VirtualScrollMode, +): { start: number; end: number } | null { + if (virtualScrollMode !== 'tanstack' || rowCount === 0 || range === null) { + return null + } + + const lastRowIndex = rowCount - 1 + const start = Math.min(range.startIndex, lastRowIndex) + return { + start, + end: Math.min(Math.max(start, range.endIndex), lastRowIndex), + } +} + +function readRows( + table: ReturnType, + quoteSnapshot: Array, + coreState: CoreTableState, +) { + void quoteSnapshot + void coreState + return readMeasuredRows(() => table.getRowModel().rows) +} + +function cellSelectionRowKey( + ranges: CellSelectionState, + bounds: Array, + rowIndex: number, + rowId: string, +): string { + const active = ranges.at(-1) + const initial = + active?.anchorRowId === rowId ? `f${active.anchorColumnId}` : '' + + return bounds.reduce((key, bound) => { + const self = rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex + const above = + rowIndex - 1 >= bound.minRowIndex && rowIndex - 1 <= bound.maxRowIndex + const below = + rowIndex + 1 >= bound.minRowIndex && rowIndex + 1 <= bound.maxRowIndex + if (!self && !above && !below) return key + return `${key}|${self ? 1 : 0}${above ? 1 : 0}${below ? 1 : 0}:${bound.minColumnIndex}-${bound.maxColumnIndex}` + }, initial) +} diff --git a/examples/preact/realtime-trading/src/vite-env.d.ts b/examples/preact/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/preact/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/preact/realtime-trading/tests/e2e/smoke.spec.ts b/examples/preact/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..e7b76873c5 --- /dev/null +++ b/examples/preact/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Preact realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/preact/realtime-trading/tsconfig.json b/examples/preact/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..2172bff227 --- /dev/null +++ b/examples/preact/realtime-trading/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/preact/realtime-trading/vite.config.ts b/examples/preact/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..d6fc34647f --- /dev/null +++ b/examples/preact/realtime-trading/vite.config.ts @@ -0,0 +1,10 @@ +import preact from '@preact/preset-vite' +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 7780, + allowedHosts: true, + }, + plugins: [preact()], +}) diff --git a/examples/react/realtime-trading/README.md b/examples/react/realtime-trading/README.md new file mode 100644 index 0000000000..e9faaa0a40 --- /dev/null +++ b/examples/react/realtime-trading/README.md @@ -0,0 +1,252 @@ +# React realtime trading benchmark + +This is a standalone stress example for the current TanStack React Table +adapter. It combines a high-frequency synthetic market feed, immutable row +snapshots, sortable and resizable columns, range selection, dynamic React cell +components, optional row virtualization, React Profiler measurements, and +browser performance instrumentation. + +The goal is to make feed work, message delivery, state application, table work, +React commits, and browser layout independently observable. It is a repeatable +rendering benchmark, not an exchange or network simulator. + +## Run and verify + +```bash +pnpm --dir examples/react/realtime-trading dev +``` + +Open `http://localhost:7778`. + +For React commit timings, use the profiling production build: + +```bash +pnpm --dir examples/react/realtime-trading build:profile +``` + +The normal verification commands are: + +```bash +pnpm --dir examples/react/realtime-trading test:types +pnpm --dir examples/react/realtime-trading lint +pnpm --dir examples/react/realtime-trading build +pnpm --dir examples/react/realtime-trading test:e2e +``` + +Development builds include React checks and produce deliberately pessimistic +timings. The standard production build disables Profiler callbacks; the +`profile` mode aliases the profiling React DOM client so commit metrics remain +available. + +## Directory structure + +| Path | Responsibility | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `src/feed/` | Market types, instruments, feed configuration, immutable update helpers, controller, and React lifecycle hook. | +| `src/feed/worker/` | Worker protocol, deterministic market engine, and the module Web Worker. | +| `src/benchmark/` | Browser monitor, benchmark controller, table observers, and React Profiler integration. | +| `src/shell/` | Controller contexts, full-viewport shell, metrics, configurator, diagnostics, selected instrument, and status bar. | +| `src/table/table-config/` | Grouped columns, row boundary, custom cells, and render counters. | +| `src/table/` | Table instance, subscription boundaries, interactions, pointer hook, initial fit, and virtualization. | +| `src/App.tsx` | Composition root; creates controllers, providers, shell, table, and Profiler boundary. | + +Feed state and benchmark state are intentionally separate. `MarketFeedController` +can run without `TradingBenchmarkController`; the latter observes feed lifecycle +events and owns only controls/metrics used by this benchmark UI. + +## Data and worker pipeline + +The initial configuration is 100 instruments, 10K generated samples per +second, a 20 ms delivery interval, enabled intraday charts, and 16 ms intraday +sampling. + +1. `MarketFeedController` starts a module worker and sends the active config. +2. `market-feed-engine.ts` mutates worker-private quote state using a + deterministic PRNG and stable instrument IDs. +3. A 16 ms loop accrues a fractional sample budget and produces the requested + amount of synthetic market work. +4. A `Map` keyed by row index retains the latest pending update for each + instrument, coalescing repeated samples. +5. A separate timer publishes at the selected delivery interval. Worker work + rate and `postMessage` rate are deliberately independent. +6. The main thread creates a new outer quotes array and replaces only the rows + present in the message. Unchanged row objects remain referentially stable. +7. History arrays change only on an intraday sample. A session ID discards stale + messages after reset or instrument-count changes. + +UI terminology: + +- **Synthetic quote workload** means generated samples per second inside the + worker, not browser events, React renders, or messages. +- **Worker delivery interval** is the target coalesced-message cadence; 20 ms + targets approximately 50 messages per second. +- **Changed rows** counts immutable row objects applied on the main thread. + Rows are deduplicated within one snapshot, but the same row can be counted + again in a later snapshot, so this is intentionally a throughput rate rather + than a count of distinct instruments over the whole second. +- **Message samples** is the generated work represented by the latest message. + +Intraday history sampling is independent of the quote workload, and the 25K +burst creates one intentionally expensive immediate batch. The worker resembles +an upstream WebSocket/SSE producer but does not include network latency. + +## React state and rendering architecture + +- `MarketFeedController` exposes each feed value as a direct TanStack atom: + `quotes`, status, row count, workload, delivery, and chart controls do not + share one aggregate `MarketFeedState` notification. +- `quotes` is its own high-frequency atom. The table subscribes to it directly, + while the header/configurator subscribe only to their lower-frequency atoms. +- Benchmark summary state remains a TanStack Store because its metrics are + published together as one coherent snapshot. +- `createStoreContext` provides the controllers without making every consumer + subscribe to the complete state object. +- Shell components call `useSelector` with narrow slices and shallow comparison + where a selector returns an object. +- Selected symbol and renderer mode are dedicated atoms because they have + different consumers and update frequencies from aggregate diagnostics. +- `TradingTable` subscribes directly to quote data and the virtualization + inputs it needs; the parent shell does not receive the quotes array. +- `table.Subscribe` boundaries isolate sorting/filtering/order changes, each + resize handle, and each row's selection state. +- Stable row IDs come from instrument IDs through `getRowId`. + +React Compiler is enabled in the Vite Babel pipeline for `src`. The current +adapter is compiled normally; there is no historical v8 implementation or +`use no memo` compatibility boundary in this example. Compiler optimization +does not replace correct subscription ownership: components still subscribe to +the smallest practical state slice. + +## Table behavior + +The grid has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart sections. It provides: + +- core sorting and filtering; +- on-change column resizing with double-click reset; +- drag-and-drop leaf-column ordering; +- CSS-only row hover; +- click/modified-click row selection; +- mouse-drag cell range selection and keyboard navigation; +- custom Price, Move, Percent Change, and Sparkline components; +- an opt-in mode that swaps move component A/B as direction changes. + +`useTradingGridPointer` installs handlers once on `tbody`. It uses +`event.composedPath()` and data attributes to resolve the TanStack cell, so the +table does not allocate pointer handlers for every cell. Refs retain transient +drag state without triggering React renders. + +Column sizes are written to CSS custom properties only when sizing or ordering +changes. Cells reference those properties, avoiding width-object churn during +quote updates. A `ResizeObserver` expands the initial column sizes to the +available viewport; the first manual resize disables later automatic fitting. + +## Full DOM and virtual rows + +The internal preference is `auto`, `tanstack`, or `none`: + +- Below 200 rows, `auto` resolves to **Full DOM**, while virtualization remains + manually selectable. +- From 200 through 1,499 rows, `auto` resolves to **TanStack Virtual**, but the + user may still choose Full DOM. +- At 1,500 rows or more, virtualization is forced and the control is locked. + +React Virtual uses a fixed 32 px estimate, 10-row overscan, stable row IDs as +item keys, transformed rows, and a body-sized spacer. The footer reports the +current range from the virtualizer. Full DOM maps the complete row model. + +Both paths apply `content-visibility: auto` with a matching intrinsic height. +For Full DOM this can reduce browser work but not React element creation or DOM +mount count. Virtualization is what limits mounted row components. + +## Performance decisions + +- Market calculations run outside the main thread. +- Repeated samples are coalesced before worker messaging. +- Immutable snapshots preserve untouched row/history references. +- Stable row keys and `getRowId` preserve identity through sorting. +- Controller contexts carry stable objects; selectors subscribe to slices. +- Table, header, resize handle, row selection, and cell renderer boundaries are + independently subscribable. +- Pointer selection is delegated and transient pointer state lives in refs. +- Column widths use CSS variables instead of per-tick style recalculation. +- Dynamic component destruction is opt-in; stable components are the baseline. +- Virtualization limits React and DOM work at larger row counts. +- Benchmark publication is throttled separately from the feed. + +A new outer data array is required to publish immutable state. Referentially +stable untouched rows, keyed rows, and narrow subscriptions reduce downstream +work; they do not guarantee that the table's sorted/filtered row model can skip +all processing when its data input changes. + +## Diagnostics + +The compact **Live health** block lives in the configurator sidebar and keeps +four cross-framework signals visible: + +- **Frame rate (est.)** counts `requestAnimationFrame` callbacks over a rolling + one-second window. It is a main-thread scheduling signal capped by the + display refresh rate, not proof of GPU-presented frames or a React Scan FPS + value. +- **Average commit latency** is the rolling three-second average from the first + pending market mutation to the table's DOM commit. Several snapshots may be + coalesced behind one commit, so this includes scheduling, React work, and DOM + commit latency; it is not component render duration. +- **Long frames** comes from the browser Long Animation Frames API and is + cumulative since reset. The worst duration is shown when supported. +- **Throughput** pairs changed rows/s with applied snapshots/s. Changed rows are + deduplicated per snapshot, not across the full reporting window. + +The detailed section retains generated worker samples, messages, state applies, +table DOM commits, a 10-second commit-latency p95/max, cumulative slow commits, +mounted hosts, component lifecycle counts, callbacks by column, executions by +component type, DOM `MutationRecord` rate, core row-model timing, and optional +heap information. + +The React Profiler additionally records actual/base duration and commit counts +when using development or the profiling build. The in-memory latency, Profiler, +and row-model aggregates inspect every call, but the Performance timeline emits +only one User Timing measure per 20 calls to avoid making instrumentation a +significant part of a hot run. Old measures are periodically pruned. + +The `MutationObserver` watches the table body for text/children and only +`class`/`style` attribute changes. Its value is browser `MutationRecord`s per +second, not DOM operations: a record can represent a coalesced change, and +framework write patterns can produce different record counts for equivalent +screens. Creating records also has real overhead at high rates, so treat it as +a diagnostic and confirm important comparisons with a Performance recording. +Heap is Chrome-only `usedJSHeapSize`, changes with garbage-collection timing, +and is useful as a trend rather than a leak verdict. + +The monitor itself schedules one lightweight `requestAnimationFrame` callback +and publishes its sidebar snapshot every 500 ms. The sidebar subscribes to that +snapshot independently, so publishing diagnostics does not update the quotes +atom or table data boundary, but it is still instrumentation overhead and must +remain enabled in both sides of an A/B comparison. React Profiler and React +DevTools also add overhead by design. + +Callback counts are not DOM mutation counts. A cell callback can execute while +React reuses the existing component and DOM. Likewise, a rising heap during the +component-swap stress test is not proof of a leak until repeated post-GC heap +snapshots show retained instances. + +Use Chrome Performance and React DevTools for call stacks/flamegraphs, and keep +the instrument count, workload, delivery interval, renderer mode, build mode, +and virtualization mode fixed between comparisons. + +React Scan remains an optional development-only aid in this example. It is not +used by any canonical counter because the other adapter examples cannot share +it, and its instrumentation can alter a recording. Use the profiling production +build without React Scan for cross-framework comparisons. + +## Standalone example policy + +This directory owns its instrument list, feed engine, worker, diagnostics, +styles, and UI instead of importing a common demo package. The duplication is +intentional: each adapter example must run independently and remain easy to +copy to StackBlitz. Shared architectural explanations are repeated in the +READMEs for the same reason. + +The package pins `@tanstack/react-table` to the repository version. The root +workspace override resolves it to `packages/react-table` while preserving a +release-like manifest. diff --git a/examples/react/realtime-trading/index.html b/examples/react/realtime-trading/index.html new file mode 100644 index 0000000000..c3dac68d53 --- /dev/null +++ b/examples/react/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + + + + React Real-time Trading flexRender Lab + + +
+ + + diff --git a/examples/react/realtime-trading/package.json b/examples/react/realtime-trading/package.json new file mode 100644 index 0000000000..b6e9231ab3 --- /dev/null +++ b/examples/react/realtime-trading/package.json @@ -0,0 +1,32 @@ +{ + "name": "tanstack-react-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "build:profile": "vite build --mode profile", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/react-table": "9.1.2", + "@tanstack/react-virtual": "^3.14.9", + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@rolldown/plugin-babel": "^0.2.3", + "@rollup/plugin-replace": "^6.0.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/react/realtime-trading/src/App.tsx b/examples/react/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..2df39ff073 --- /dev/null +++ b/examples/react/realtime-trading/src/App.tsx @@ -0,0 +1,34 @@ +import { Profiler } from 'react' +import { useMarketFeedController } from './feed/use-market-feed-controller' +import { useTradingBenchmarkController } from './benchmark/use-trading-benchmark-controller' +import { TradingShell } from './shell/TradingShell' +import { + TradingShellProvider, + useTradingShellController, +} from './shell/trading-shell-context' +import { TradingTable } from './table/trading-table' + +export function App() { + const feed = useMarketFeedController() + const controller = useTradingBenchmarkController(feed) + return ( + + + + + + ) +} + +function TradingTableOutlet() { + const controller = useTradingShellController() + + return ( + + + + ) +} diff --git a/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..7d37abb354 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,500 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' +import type { ProfilerOnRenderCallback } from 'react' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + estimatedFrameRate: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + profilerCommitsPerSecond: number + profilerAverageActualMs: number + profilerP95ActualMs: number + profilerAverageBaseMs: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + estimatedFrameRate: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + profilerCommitsPerSecond: 0, + profilerAverageActualMs: 0, + profilerP95ActualMs: 0, + profilerAverageBaseMs: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { + entryCount: 0, + measureCallsByName: {} as Record, +} + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + const measureCall = (userTiming.measureCallsByName[name] ?? 0) + 1 + userTiming.measureCallsByName[name] = measureCall + if ((measureCall - 1) % 20 !== 0) return + + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('react-profiler-commit') + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +interface ProfilerSample { + actualDuration: number + baseDuration: number +} + +export class BenchmarkMonitor { + readonly #runtime = { + sessionStartedAt: performance.now(), + sampleStartedAt: performance.now(), + pendingCommitStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + profilerSamples: [] as Array, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + readonly recordProfilerRender: ProfilerOnRenderCallback = ( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + ) => { + this.#runtime.profilerSamples.push({ actualDuration, baseDuration }) + recordMeasure('react-profiler-commit', startTime, commitTime, { + id, + phase, + actualDuration, + baseDuration, + }) + } + + markCommitPending(): void { + this.#runtime.pendingCommitStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingCommitStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingCommitStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingCommitStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingCommitStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const profilerSamples = runtime.profilerSamples + const sortedProfilerSamples = profilerSamples + .map((sample) => sample.actualDuration) + .sort((left, right) => left - right) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const profilerP95Index = Math.max( + 0, + Math.ceil(sortedProfilerSamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + estimatedFrameRate: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + profilerCommitsPerSecond: + (profilerSamples.length / sampleDuration) * 1_000, + profilerAverageActualMs: + profilerSamples.length === 0 + ? 0 + : profilerSamples.reduce( + (sum, sample) => sum + sample.actualDuration, + 0, + ) / profilerSamples.length, + profilerP95ActualMs: sortedProfilerSamples[profilerP95Index] ?? 0, + profilerAverageBaseMs: + profilerSamples.length === 0 + ? 0 + : profilerSamples.reduce( + (sum, sample) => sum + sample.baseDuration, + 0, + ) / profilerSamples.length, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.profilerSamples = [] + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sessionStartedAt = performance.now() + runtime.sampleStartedAt = runtime.sessionStartedAt + runtime.pendingCommitStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.profilerSamples = [] + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/react/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/react/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..625ceffcb5 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/react-store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts b/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts new file mode 100644 index 0000000000..bc335d2477 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/use-table-benchmark.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function useTableBenchmark( + controller: TradingBenchmarkController, +): void { + useEffect(() => { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) { + return + } + + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() + }, [controller]) +} diff --git a/examples/react/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts b/examples/react/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts new file mode 100644 index 0000000000..b163778419 --- /dev/null +++ b/examples/react/realtime-trading/src/benchmark/use-trading-benchmark-controller.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' +import { TradingBenchmarkController } from './trading-benchmark-controller' +import type { MarketFeedController } from '../feed/market-feed-controller' + +export function useTradingBenchmarkController(feed: MarketFeedController) { + const [controller] = useState(() => new TradingBenchmarkController(feed)) + + useEffect(() => controller.start(), [controller]) + + return controller +} + +export type { TradingBenchmarkController } from './trading-benchmark-controller' diff --git a/examples/react/realtime-trading/src/feed/feed-sample-rates.ts b/examples/react/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/react/realtime-trading/src/feed/market-data.ts b/examples/react/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/react/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/react/realtime-trading/src/feed/market-feed-config.ts b/examples/react/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/react/realtime-trading/src/feed/market-feed-controller.ts b/examples/react/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..eb90009b83 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/react-store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/react/realtime-trading/src/feed/market-instruments.ts b/examples/react/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/react/realtime-trading/src/feed/use-market-feed-controller.ts b/examples/react/realtime-trading/src/feed/use-market-feed-controller.ts new file mode 100644 index 0000000000..74c5208f38 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/use-market-feed-controller.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from 'react' +import { MarketFeedController } from './market-feed-controller' + +export function useMarketFeedController(): MarketFeedController { + const [controller] = useState(() => new MarketFeedController()) + + useEffect(() => controller.start(), [controller]) + + return controller +} diff --git a/examples/react/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/react/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/react/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/react/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/react/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/react/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/react/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/react/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/react/realtime-trading/src/index.css b/examples/react/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/react/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/react/realtime-trading/src/main.tsx b/examples/react/realtime-trading/src/main.tsx new file mode 100644 index 0000000000..cd7ad7fb78 --- /dev/null +++ b/examples/react/realtime-trading/src/main.tsx @@ -0,0 +1,17 @@ +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +if (import.meta.env.DEV) { + const reactScan = document.createElement('script') + reactScan.src = 'https://unpkg.com/react-scan/dist/auto.global.js' + reactScan.async = true + document.head.append(reactScan) +} + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +// StrictMode is intentionally omitted: its development-only mount replay would +// contaminate the component lifecycle counters used by this benchmark. +createRoot(rootElement).render() diff --git a/examples/react/realtime-trading/src/shell/TradingShell.tsx b/examples/react/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..b5c2e12da5 --- /dev/null +++ b/examples/react/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,685 @@ +import { shallow, useSelector } from '@tanstack/react-store' +import { useState } from 'react' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { + useMarketFeedController, + useTradingShellController, + useTradingShellState, +} from './trading-shell-context' +import { configuratorOptions } from './configurator-options' +import type { ReactNode } from 'react' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export function TradingShell(props: { children: ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(true) + const toggleSidebar = () => setSidebarOpen((open) => !open) + + return ( +
+
+ + + {import.meta.env.DEV && ( + + )} + + {import.meta.env.MODE === 'production' && ( + + )} +
+ +
+ {props.children} +
+ +
{sidebarOpen && }
+
+ ) +} + +function AppHeader(props: { + sidebarOpen: boolean + onSidebarToggle: () => void +}) { + const feed = useMarketFeedController() + const workerReady = useSelector(feed.workerReady) + const running = useSelector(feed.running) + return ( +
+
+ MARKET MONITOR +
+
+ + + +
+
+ ) +} + +function MarketStatusbar() { + const { lastBatchSize, lastUpdateCount, mountedCells, liveComponents } = + useTradingShellState( + (state) => ({ + lastBatchSize: state.metrics.lastBatchSize, + lastUpdateCount: state.metrics.lastUpdateCount, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + }), + { compare: shallow }, + ) + return ( +
+ + MESSAGE SAMPLES {formatInteger(lastBatchSize)} + + + CHANGED ROWS {formatInteger(lastUpdateCount)} + + + HOSTS {formatInteger(mountedCells)} + + + COMPONENTS {formatInteger(liveComponents)} + +
+ ) +} + +function Configurator() { + const benchmarkState = useTradingShellState( + (storeState) => ({ + requestedVirtualScrollMode: storeState.requestedVirtualScrollMode, + }), + { compare: shallow }, + ) + const controller = useTradingShellController() + const feed = useMarketFeedController() + const running = useSelector(feed.running) + const instrumentCount = useSelector(feed.instrumentCount) + const targetTicksPerSecond = useSelector(feed.targetTicksPerSecond) + const publishIntervalMs = useSelector(feed.publishIntervalMs) + const updateSparklines = useSelector(feed.updateSparklines) + const sparklineSampleIntervalMs = useSelector(feed.sparklineSampleIntervalMs) + const rendererMode = useSelector(controller.renderAtoms.rendererMode) + const { actions } = controller + const feedActions = feed.actions + const { requestedVirtualScrollMode } = benchmarkState + const { setRendererMode, setVirtualScrollEnabled, resetMarket } = actions + const { + toggle, + setInstrumentCount, + setTargetRate, + setPublishInterval, + setSparklineUpdates, + setSparklineSampleInterval, + runBurst, + } = feedActions + const virtualScrollForced = instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT + const virtualScrollMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + + return ( + + ) +} + +function LiveHealth() { + const { metrics, longAnimationFramesSupported } = useTradingShellState( + (state) => ({ + metrics: state.metrics, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + return ( +
+

LIVE HEALTH

+
+ FRAME RATE (EST.) + + {metrics.estimatedFrameRate.toFixed(1)} + + rAF callbacks/s · rolling 1 s +
+
+ AVG COMMIT + + {formatMs(metrics.averageCommitLatencyMs)} + + snapshot → DOM · rolling 3 s +
+
+ LONG FRAMES + {longAnimationFramesSupported ? ( + <> + 0 ? 'metric-alert' : ''} + > + {metrics.longAnimationFrames} + + + since reset · worst {formatMs(metrics.worstLongAnimationFrameMs)} + + + ) : ( + <> + N/A + unsupported + + )} +
+
+ THROUGHPUT + + {formatRate(metrics.rowUpdatesPerSecond)} rows/s + + + {metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows + deduplicated per snapshot + +
+
+ ) +} + +function Diagnostics() { + const { + metrics, + mountedCells, + liveComponents, + longAnimationFramesSupported, + } = useTradingShellState( + (state) => ({ + metrics: state.metrics, + mountedCells: state.mountedCells, + liveComponents: state.liveComponents, + longAnimationFramesSupported: state.longAnimationFramesSupported, + }), + { compare: shallow }, + ) + const profilerEnabled = + import.meta.env.DEV || import.meta.env.MODE === 'profile' + return ( +
+

DIAGNOSTICS

+
+
+
Worker samples / s
+
+ {formatRate(metrics.actualTicksPerSecond)} +
+
+
+
Changed rows / s
+
+ {formatRate(metrics.rowUpdatesPerSecond)} +
+
+
+
Worker messages / s
+
+ {metrics.workerMessagesPerSecond.toFixed(1)} +
+
+
+
State applies / s
+
+ {metrics.stateApplicationsPerSecond.toFixed(1)} +
+
+
+
Table DOM commits / s
+
+ {metrics.tableCommitsPerSecond.toFixed(1)} +
+
+
+
Commit latency p95 / max
+
+ {formatMs(metrics.p95CommitLatencyMs)} /{' '} + {formatMs(metrics.maxCommitLatencyMs)} +
+
+
+
Mounted cells
+
{formatInteger(mountedCells)}
+
+
+
Live components
+
{formatInteger(liveComponents)}
+
+
+
Created / destroyed
+
+ {formatInteger(metrics.componentsCreated)} /{' '} + {formatInteger(metrics.componentsDestroyed)} +
+
+
+
Renderer callbacks / s
+
+ {formatRate(metrics.cellRendererCallsPerSecond)} +
+
+
+
Component executions / s
+
+ {formatRate(metrics.componentRenderCallsPerSecond)} +
+
+
+
Executions by component / s
+
+ {formatInvocationRates(metrics.componentRenderRates)} +
+
+
+
Callbacks by column / s
+
+ {formatInvocationRates(metrics.cellRendererRates)} +
+
+
+
Observed MutationRecords / s
+
+ {formatRate(metrics.domMutationsPerSecond)} +
+
+
+
React Profiler commits / s
+
+ {profilerEnabled + ? metrics.profilerCommitsPerSecond.toFixed(1) + : 'PROFILE BUILD REQUIRED'} +
+
+
+
Profiler actual avg / p95
+
+ {profilerEnabled + ? `${formatMs(metrics.profilerAverageActualMs)} / ${formatMs( + metrics.profilerP95ActualMs, + )}` + : 'N/A'} +
+
+
+
Profiler base avg
+
+ {profilerEnabled ? formatMs(metrics.profilerAverageBaseMs) : 'N/A'} +
+
+
+
Core row model calls / s
+
+ {metrics.rowModelCallsPerSecond.toFixed(1)} +
+
+
+
Core row model avg / max
+
+ {formatMs(metrics.rowModelAverageMs)} /{' '} + {formatMs(metrics.rowModelMaxMs)} +
+
+
+
Visible rows
+
+ {formatInteger(metrics.visibleRows)} +
+
+
+
Worker messages
+
+ {formatInteger(metrics.workerMessages)} +
+
+
+
Worker-coalesced updates / s
+
+ {formatRate(metrics.supersededUpdatesPerSecond)} +
+
+
+
Last message samples / updated rows
+
+ {formatInteger(metrics.lastBatchSize)} /{' '} + {formatInteger(metrics.lastUpdateCount)} +
+
+
+
Commits > 16.7 ms since reset
+
{metrics.slowCommits}
+
+
+
Long animation frames
+
+ {longAnimationFramesSupported + ? formatInteger(metrics.longAnimationFrames) + : 'Unsupported'} +
+
+
+
+ JS heap (GC-sensitive) +
+
+ {metrics.heapMb === null + ? 'N/A' + : `${metrics.heapMb.toFixed(1)} MB`} +
+
+
+
+ ) +} + +function SelectedInstrument() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const selectedSymbol = useSelector(controller.renderAtoms.selectedSymbol) + const quotes = useSelector(feed.quotes) + const selectedQuote = feed.getQuoteBySymbol(quotes, selectedSymbol) + return ( +
+

SELECTED INSTRUMENT

+ {selectedQuote ? ( + <> +
+
+ {selectedQuote.symbol} + {selectedQuote.company} +
+ {selectedQuote.venue} +
+
+
+
Last
+
{selectedQuote.price.toFixed(2)}
+
+
+
Bid / ask
+
+ {selectedQuote.bid.toFixed(2)} / {selectedQuote.ask.toFixed(2)} +
+
+
+ + ) : ( +

+ Click or begin a cell selection in any row to inspect its instrument. +

+ )} +
+ ) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['componentRenderRates'], +): string { + const activeRates = rates + .filter((rate) => rate.callsPerSecond > 0) + .sort((left, right) => right.callsPerSecond - left.callsPerSecond) + return activeRates.length === 0 + ? 'No calls in sample' + : activeRates + .map((rate) => `${rate.name} ${formatRate(rate.callsPerSecond)}`) + .join(' · ') +} diff --git a/examples/react/realtime-trading/src/shell/configurator-options.ts b/examples/react/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/react/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/react/realtime-trading/src/shell/trading-shell-context.tsx b/examples/react/realtime-trading/src/shell/trading-shell-context.tsx new file mode 100644 index 0000000000..1c876ec374 --- /dev/null +++ b/examples/react/realtime-trading/src/shell/trading-shell-context.tsx @@ -0,0 +1,50 @@ +import { createStoreContext, useSelector } from '@tanstack/react-store' +import type { ReactNode } from 'react' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/use-trading-benchmark-controller' +import type { TradingBenchmarkState } from '../benchmark/trading-benchmark-controller' +import type { UseSelectorOptions } from '@tanstack/react-store' + +const { + StoreProvider: TradingStoreProvider, + useStoreContext: useTradingShellController, +} = createStoreContext() + +const { + StoreProvider: MarketFeedStoreProvider, + useStoreContext: useMarketFeedController, +} = createStoreContext() + +export function MarketFeedProvider(props: { + controller: MarketFeedController + children: ReactNode +}) { + return ( + + {props.children} + + ) +} + +export function TradingShellProvider(props: { + controller: TradingBenchmarkController + children: ReactNode +}) { + return ( + + + {props.children} + + + ) +} + +export { useMarketFeedController, useTradingShellController } + +export function useTradingShellState( + selector: (state: TradingBenchmarkState) => TSelected, + options?: UseSelectorOptions, +): TSelected { + const controller = useTradingShellController() + return useSelector(controller.store, selector, options) +} diff --git a/examples/react/realtime-trading/src/table/table-config/quote-cells.tsx b/examples/react/realtime-trading/src/table/table-config/quote-cells.tsx new file mode 100644 index 0000000000..1c5ff4523d --- /dev/null +++ b/examples/react/realtime-trading/src/table/table-config/quote-cells.tsx @@ -0,0 +1,219 @@ +import { useEffect } from 'react' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + 'use no memo' + // Benchmark instrumentation is intentionally impure and must run per call. + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +function useLifecycleCounter(componentName: QuoteComponentName): void { + 'use no memo' + // This diagnostic mutation measures component-function invocation itself. + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + useEffect(() => { + quoteCellLifecycle.created++ + return () => { + quoteCellLifecycle.destroyed++ + } + }, []) +} + +export function PriceCell(props: { + price: number + move: number + onSelect: () => void +}) { + useLifecycleCounter('PriceCell') + return ( + + ) +} + +export function StableMoveCell({ move }: { move: number }) { + useLifecycleCounter('StableMoveCell') + return ( + = 0 ? 'quote-up' : 'quote-down'}`}> + {formatSigned(move)} + + ) +} + +export function UpMoveCell({ move }: { move: number }) { + useLifecycleCounter('UpMoveCell') + return ▲ {formatSigned(move)} +} + +export function DownMoveCell({ move }: { move: number }) { + useLifecycleCounter('DownMoveCell') + return ▼ {formatSigned(move)} +} + +export function PercentChangeCell({ value }: { value: number }) { + useLifecycleCounter('PercentChangeCell') + return ( + = 0 ? 'quote-up' : 'quote-down'}`} + > + {value >= 0 ? '+' : ''} + {value.toFixed(2)}% + + ) +} + +export function SpreadCell({ bid, ask }: { bid: number; ask: number }) { + useLifecycleCounter('SpreadCell') + const spread = Math.max(0, ask - bid) + const midpoint = (bid + ask) / 2 + const basisPoints = midpoint === 0 ? 0 : (spread / midpoint) * 10_000 + + return ( + = 4 ? 'spread-wide' : ''}`}> + {spread.toFixed(2)} + {basisPoints.toFixed(1)} bp + + ) +} + +export function DepthCell(props: { bidSize: number; askSize: number }) { + useLifecycleCounter('DepthCell') + const total = props.bidSize + props.askSize + const bidShare = total === 0 ? 50 : (props.bidSize / total) * 100 + + return ( +
+ + + + {compactNumber.format(props.bidSize)} + {compactNumber.format(props.askSize)} + +
+ ) +} + +export function QuoteAgeCell({ ageMs }: { ageMs: number }) { + useLifecycleCounter('QuoteAgeCell') + const className = + ageMs >= 1_500 + ? 'quote-age quote-age-stale' + : ageMs >= 500 + ? 'quote-age quote-age-warm' + : 'quote-age' + + return ( + + {ageMs < 1_000 + ? `${Math.round(ageMs)} ms` + : `${(ageMs / 1_000).toFixed(1)} s`} + + ) +} + +export function SparklineCell({ values }: { values: ReadonlyArray }) { + useLifecycleCounter('SparklineCell') + const rising = (values.at(-1) ?? 0) >= (values[0] ?? 0) + const { min, max } = findRange(values) + const range = max - min || 1 + const denominator = Math.max(1, values.length - 1) + const points = values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + + return ( + + + + ) +} + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} + +function findRange(values: ReadonlyArray): { + min: number + max: number +} { + const first = values[0] ?? 0 + return values.reduce( + (range, value) => { + range.min = Math.min(range.min, value) + range.max = Math.max(range.max, value) + return range + }, + { min: first, max: first }, + ) +} diff --git a/examples/react/realtime-trading/src/table/table-config/trading-table-config.tsx b/examples/react/realtime-trading/src/table/table-config/trading-table-config.tsx new file mode 100644 index 0000000000..2ccc8a01f8 --- /dev/null +++ b/examples/react/realtime-trading/src/table/table-config/trading-table-config.tsx @@ -0,0 +1,301 @@ +import { useSelector } from '@tanstack/react-store' +import { useTradingShellController } from '../../shell/trading-shell-context' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import type { ReactNode } from 'react' +import type { MarketQuote } from '../../feed/market-data' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +export interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => ReactNode +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export const tradingColumns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender('Last', ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: (row) => getDayChange(row), + cell: ({ row }) => + recordCellRender('Change', ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: (row) => getDayChangePercent(row), + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + , + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender( + 'BidVolume', + compactFormatter.format(row.original.bidSize), + ), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender( + 'AskVolume', + compactFormatter.format(row.original.askSize), + ), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender( + 'Intraday', + , + ), + }, + ], + }, +] + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} + +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + + if (rowModelDiagnostics.calls % 1_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + if ((rowModelDiagnostics.calls - 1) % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } + } + + return rows +} + +function LastPriceCell(props: { quote: MarketQuote }) { + const { selectSymbol } = useTradingShellController().actions + return ( + selectSymbol(props.quote.symbol)} + /> + ) +} + +function DayChangeCell(props: { quote: MarketQuote }) { + const { rendererMode } = useTradingShellController().renderAtoms + const mode = useSelector(rendererMode) + const change = getDayChange(props.quote) + if (mode === 'stable') { + return + } + return change >= 0 ? ( + + ) : ( + + ) +} + +export function TradingRow(props: { + quote: MarketQuote + children: ReactNode + rowSelected: boolean + virtualRow?: { index: number; start: number } +}) { + const { selectedSymbol } = useTradingShellController().renderAtoms + const selected = useSelector( + selectedSymbol, + (symbol) => symbol === props.quote.symbol, + ) + return ( + + {props.children} + + ) +} + +function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/react/realtime-trading/src/table/table-interactions.ts b/examples/react/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..a7458f73ff --- /dev/null +++ b/examples/react/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,147 @@ +import type { AriaAttributes, KeyboardEvent as ReactKeyboardEvent } from 'react' + +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Partial> +} + +export interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Partial> + } +} + +export interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + nativeEvent: event, + }) +} + +export function findTradingGridCellTarget( + table: TradingGridTable, + path: Array, +): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row?.getAllCellsByColumnId()[columnId] + return row && cell ? { element: target, cell } : null + } + + return null +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: ReactKeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): AriaAttributes['aria-sort'] { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/react/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/react/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/react/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/react/realtime-trading/src/table/trading-table.tsx b/examples/react/realtime-trading/src/table/trading-table.tsx new file mode 100644 index 0000000000..8030ccbb34 --- /dev/null +++ b/examples/react/realtime-trading/src/table/trading-table.tsx @@ -0,0 +1,604 @@ +import { + FlexRender, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, + useTable, +} from '@tanstack/react-table' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useSelector } from '@tanstack/react-store' +import { useLayoutEffect, useRef } from 'react' +import { useTableBenchmark } from '../benchmark/use-table-benchmark' +import { + useMarketFeedController, + useTradingShellController, + useTradingShellState, +} from '../shell/trading-shell-context' +import { + TradingRow, + readMeasuredRows, + tradingColumns, +} from './table-config/trading-table-config' +import { + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from './table-interactions' +import { useTradingGridPointer } from './use-trading-grid-pointer' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, +} from './trading-row-virtualizer' +import type { VirtualScrollMode } from './trading-row-virtualizer' +import type { + CellSelectionBounds, + CellSelectionState, +} from '@tanstack/react-table' +import type { VirtualItem } from '@tanstack/react-virtual' +import type { MarketQuote } from '../feed/market-data' +import type { CoreTableState } from './table-config/trading-table-config' + +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-table-config' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-table-config' +export type { VirtualScrollMode } from './trading-row-virtualizer' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) + +export function TradingTable() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const quotes = useSelector(feed.quotes) + const requestedVirtualScrollMode = useTradingShellState( + (state) => state.requestedVirtualScrollMode, + ) + const instrumentCount = useSelector(feed.instrumentCount) + const virtualScrollMode = resolveVirtualScrollMode( + requestedVirtualScrollMode, + instrumentCount, + ) + + useLayoutEffect(() => feed.completeRender()) + useTableBenchmark(controller) + const table = useTradingTable({ quotes }) + const layoutRefs = useTradingTableLayout(table) + + return ( + ({ + sorting: state.sorting, + columnFilters: state.columnFilters, + columnOrder: state.columnOrder, + })} + > + {(coreState) => ( + + )} + + ) +} + +type TradingTableInstance = ReturnType + +interface ColumnDragRuntime { + columnId: string | null + sourceElement: HTMLTableCellElement | null + targetElement: HTMLTableCellElement | null +} + +function clearColumnDrag(runtime: ColumnDragRuntime): void { + runtime.sourceElement?.classList.remove('is-column-dragging') + runtime.targetElement?.classList.remove('is-column-drop-target') + runtime.columnId = null + runtime.sourceElement = null + runtime.targetElement = null +} + +function showColumnDropTarget( + runtime: ColumnDragRuntime, + targetColumnId: string, + targetElement: HTMLTableCellElement | null, +): void { + runtime.targetElement?.classList.remove('is-column-drop-target') + runtime.targetElement = null + if (runtime.columnId === targetColumnId || !targetElement) return + targetElement.classList.add('is-column-drop-target') + runtime.targetElement = targetElement +} + +function useTradingTableLayout(table: TradingTableInstance) { + const scrollRef = useRef(null) + const tableRef = useRef(null) + const fitRuntime = useRef({ manuallyResized: false }) + const tableRuntime = useRef(table) + tableRuntime.current = table + + useLayoutEffect(() => { + const writeColumnSizes = () => { + const currentTable = tableRuntime.current + const tableElement = tableRef.current + if (!tableElement) return + for (const header of currentTable.getFlatHeaders()) { + tableElement.style.setProperty( + `--header-${header.id}-size`, + String(header.getSize()), + ) + tableElement.style.setProperty( + `--col-${header.column.id}-size`, + String(header.column.getSize()), + ) + } + tableElement.style.width = `${currentTable.getTotalSize()}px` + } + + writeColumnSizes() + const sizingSubscription = + tableRuntime.current.atoms.columnSizing.subscribe(writeColumnSizes) + const orderSubscription = + tableRuntime.current.atoms.columnOrder.subscribe(writeColumnSizes) + const fitAvailableWidth = () => { + const currentTable = tableRuntime.current + const scrollElement = scrollRef.current + if (!scrollElement || fitRuntime.current.manuallyResized) return + const currentWidth = currentTable.getTotalSize() + const availableWidth = scrollElement.clientWidth + if (availableWidth <= currentWidth + 1 || currentWidth <= 0) return + + const ratio = availableWidth / currentWidth + currentTable.setColumnSizing( + Object.fromEntries( + currentTable + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + const resizeObserver = new ResizeObserver(fitAvailableWidth) + const resizingSubscription = + tableRuntime.current.atoms.columnResizing.subscribe((state) => { + if (state.isResizingColumn !== false) { + fitRuntime.current.manuallyResized = true + } + }) + if (scrollRef.current) resizeObserver.observe(scrollRef.current) + fitAvailableWidth() + + return () => { + sizingSubscription.unsubscribe() + orderSubscription.unsubscribe() + resizingSubscription.unsubscribe() + resizeObserver.disconnect() + } + }, []) + + return { scrollRef, tableRef } +} + +function TradingTableHeader(props: { table: TradingTableInstance }) { + const dragRuntime = useRef({ + columnId: null, + sourceElement: null, + targetElement: null, + }) + + return ( + ({ + columnOrder: state.columnOrder, + sorting: state.sorting, + })} + > + {() => ( + + {props.table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isLeaf = header.subHeaders.length === 0 + const sorted = header.column.getIsSorted() + return ( + + {!header.isPlaceholder && + (isLeaf ? ( + <> +
{ + event.preventDefault() + showColumnDropTarget( + dragRuntime.current, + header.column.id, + event.currentTarget.closest('th'), + ) + }} + onDrop={(event) => { + event.preventDefault() + const sourceId = + event.dataTransfer.getData('text/plain') || + dragRuntime.current.columnId + if (sourceId) { + props.table.setColumnOrder( + reorderColumnIds( + props.table + .getVisibleLeafColumns() + .map((column) => column.id), + sourceId, + header.column.id, + ), + ) + } + clearColumnDrag(dragRuntime.current) + }} + > + + +
+ {header.column.getCanResize() && ( + + state.columnResizing.isResizingColumn === + header.column.id + } + > + {(isResizing) => ( +
+ header.column.resetSize() + } + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + /> + )} + + )} + + ) : ( + + ))} + + ) + })} + + ))} + + )} + + ) +} + +function useTradingTable(props: { quotes: Array }) { + return useTable( + { + key: 'react-realtime-trading', + features, + columns: tradingColumns, + data: props.quotes, + getRowId: (row) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }, + () => null, + ) +} + +function TradingTableViewport(props: { + table: ReturnType + rows: ReturnType['getRowModel']>['rows'] + sourceRowCount: number + layoutRefs: ReturnType + virtualScrollMode: VirtualScrollMode + reportRenderedRowCount: (count: number) => void +}) { + const rowVirtualizer = useVirtualizer({ + count: props.rows.length, + estimateSize: () => TRADING_ROW_HEIGHT, + getScrollElement: () => props.layoutRefs.scrollRef.current, + getItemKey: (index) => props.rows[index]?.id ?? index, + overscan: TRADING_ROW_OVERSCAN, + enabled: props.virtualScrollMode === 'tanstack', + }) + const virtualRows = rowVirtualizer.getVirtualItems() + const renderedRowCount = + props.virtualScrollMode === 'tanstack' + ? virtualRows.length + : props.rows.length + const visibleRange = readVisibleRange( + rowVirtualizer.range, + props.rows.length, + props.virtualScrollMode, + ) + + useLayoutEffect(() => { + props.reportRenderedRowCount(renderedRowCount) + }, [props.reportRenderedRowCount, renderedRowCount]) + + return ( + <> +
handleCellNavigation(props.table, event)} + > + + + +
+
+ {props.virtualScrollMode === 'tanstack' && ( +
+ + TanStack · Total · {props.rows.length} rows ·{' '} + {props.table.getVisibleLeafColumns().length} columns + + + {visibleRange + ? `Current · rows ${visibleRange.start}..${visibleRange.end}` + : 'Current · rows —'} + +
+ )} + + ) +} + +function TradingRows(props: { + table: ReturnType + rows: ReturnType['getRowModel']>['rows'] + sourceRowCount: number + virtualRows: Array + virtualScrollMode: VirtualScrollMode +}) { + const { selectSymbol } = useTradingShellController().actions + const pointerInteractions = useTradingGridPointer(props.table, selectSymbol) + + return ( + + {props.virtualScrollMode === 'tanstack' + ? props.virtualRows.map((virtualRow) => { + const row = props.rows[virtualRow.index] + return ( + + ) + }) + : props.rows.map((row) => ( + + ))} + + ) +} + +type TradingTableRow = ReturnType< + ReturnType['getRowModel'] +>['rows'][number] + +function TradingRowBoundary(props: { + table: ReturnType + row: TradingTableRow + virtualRow?: VirtualItem +}) { + const { row, table, virtualRow } = props + + return ( + + `${row.id in state.rowSelection ? 1 : 0}:${cellSelectionRowKey( + state.cellSelection, + table.getCellSelectionBounds(), + row.getDisplayIndex(), + row.id, + )}` + } + > + {() => ( + + {row.getVisibleCells().map((cell) => { + const edges = cell.getSelectionEdges() + + return ( + + + + ) + })} + + )} + + ) +} + +function getHeaderClassName(header: { + subHeaders: ReadonlyArray + column: { id: string } +}): string | undefined { + if (header.subHeaders.length > 0) return 'column-group-header' + return isTextColumn(header.column.id) ? undefined : 'numeric-header' +} + +function isTextColumn(columnId: string): boolean { + return columnId === 'market' || columnId === 'name' || columnId === 'symbol' +} + +function readVisibleRange( + range: { startIndex: number; endIndex: number } | null, + rowCount: number, + virtualScrollMode: VirtualScrollMode, +): { start: number; end: number } | null { + if (virtualScrollMode !== 'tanstack' || rowCount === 0 || range === null) { + return null + } + + const lastRowIndex = rowCount - 1 + const start = Math.min(range.startIndex, lastRowIndex) + return { + start, + end: Math.min(Math.max(start, range.endIndex), lastRowIndex), + } +} + +function readRows( + table: ReturnType, + quoteSnapshot: Array, + coreState: CoreTableState, +) { + void quoteSnapshot + void coreState + return readMeasuredRows(() => table.getRowModel().rows) +} + +function cellSelectionRowKey( + ranges: CellSelectionState, + bounds: Array, + rowIndex: number, + rowId: string, +): string { + const active = ranges.at(-1) + const initial = + active?.anchorRowId === rowId ? `f${active.anchorColumnId}` : '' + + return bounds.reduce((key, bound) => { + const self = rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex + const above = + rowIndex - 1 >= bound.minRowIndex && rowIndex - 1 <= bound.maxRowIndex + const below = + rowIndex + 1 >= bound.minRowIndex && rowIndex + 1 <= bound.maxRowIndex + if (!self && !above && !below) return key + return `${key}|${self ? 1 : 0}${above ? 1 : 0}${below ? 1 : 0}:${bound.minColumnIndex}-${bound.maxColumnIndex}` + }, initial) +} diff --git a/examples/react/realtime-trading/src/table/use-trading-grid-pointer.ts b/examples/react/realtime-trading/src/table/use-trading-grid-pointer.ts new file mode 100644 index 0000000000..a2209c3d7c --- /dev/null +++ b/examples/react/realtime-trading/src/table/use-trading-grid-pointer.ts @@ -0,0 +1,76 @@ +import { useRef } from 'react' +import { + findTradingGridCellTarget, + selectRowFromPointer, +} from './table-interactions' +import type { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from 'react' +import type { TradingGridTable } from './table-interactions' + +export interface TradingGridPointerHandlers { + readonly onMouseDown: ( + event: ReactMouseEvent, + ) => void + readonly onPointerOver: ( + event: ReactPointerEvent, + ) => void + readonly onMouseLeave: () => void + readonly onClick: (event: ReactMouseEvent) => void +} + +export function useTradingGridPointer( + table: TradingGridTable, + selectSymbol: (symbol: string) => void, +): TradingGridPointerHandlers { + const lastPointerCell = useRef(null) + + return { + onMouseDown(event) { + if (event.button !== 0) return + + const nativeEvent = event.nativeEvent + const target = findTradingGridCellTarget( + table, + nativeEvent.composedPath(), + ) + if (!target) return + + nativeEvent.preventDefault() + lastPointerCell.current = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)( + nativeEvent, + ) + }, + onPointerOver(event) { + const nativeEvent = event.nativeEvent + if ((nativeEvent.buttons & 1) === 0) { + lastPointerCell.current = null + return + } + + const target = findTradingGridCellTarget( + table, + nativeEvent.composedPath(), + ) + if (!target || target.element === lastPointerCell.current) return + + lastPointerCell.current = target.element + target.cell.getSelectionExtendHandler()(nativeEvent) + }, + onMouseLeave() { + lastPointerCell.current = null + }, + onClick(event) { + const nativeEvent = event.nativeEvent + const target = findTradingGridCellTarget( + table, + nativeEvent.composedPath(), + ) + if (!target) return + selectRowFromPointer(table, target.cell.row, nativeEvent) + }, + } +} diff --git a/examples/react/realtime-trading/src/vite-env.d.ts b/examples/react/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/react/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/react/realtime-trading/tests/e2e/smoke.spec.ts b/examples/react/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..68a78d23a7 --- /dev/null +++ b/examples/react/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,198 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the React realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.route( + 'https://unpkg.com/react-scan/dist/auto.global.js', + (route) => + route.fulfill({ + contentType: 'application/javascript', + body: '', + }), + ) + + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + await expect + .poll(async () => + Number(await page.getByTestId('profiler-commit-rate').textContent()), + ) + .toBeGreaterThan(0) + expect( + await page.evaluate( + () => + performance.getEntriesByName('react-profiler-commit').length > 0 && + performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/react/realtime-trading/tsconfig.json b/examples/react/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..6bb83b8604 --- /dev/null +++ b/examples/react/realtime-trading/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/react/realtime-trading/vite.config.ts b/examples/react/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..e105049171 --- /dev/null +++ b/examples/react/realtime-trading/vite.config.ts @@ -0,0 +1,42 @@ +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import babel from '@rolldown/plugin-babel' +import rollupReplace from '@rollup/plugin-replace' +import { defineConfig } from 'vite' + +export default defineConfig(({ mode }) => { + const development = mode === 'development' + const profiling = mode === 'profile' + + return { + server: { + port: 7778, + allowedHosts: true, + }, + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(development), + 'process.env.NODE_ENV': JSON.stringify( + development ? 'development' : 'production', + ), + }, + }), + react(), + babel({ + presets: [reactCompilerPreset()], + include: [/\/src\/.*\.[jt]sx?$/], + }), + ], + resolve: { + alias: profiling + ? [ + { + find: /^react-dom\/client$/, + replacement: 'react-dom/profiling', + }, + ] + : [], + }, + } +}) diff --git a/examples/solid/realtime-trading/README.md b/examples/solid/realtime-trading/README.md new file mode 100644 index 0000000000..5a3008e60f --- /dev/null +++ b/examples/solid/realtime-trading/README.md @@ -0,0 +1,195 @@ +# Solid realtime trading benchmark + +This standalone example exercises the current TanStack Solid Table adapter +with a high-frequency worker feed, immutable snapshots, fine-grained reactive +cells, table interactions, virtual rows, and browser diagnostics. It is a +repeatable UI workload, not an exchange or network benchmark. + +## Run and verify + +```bash +pnpm --dir examples/solid/realtime-trading dev +``` + +Open `http://localhost:7779`. + +```bash +pnpm --dir examples/solid/realtime-trading test:types +pnpm --dir examples/solid/realtime-trading lint +pnpm --dir examples/solid/realtime-trading build +pnpm --dir examples/solid/realtime-trading test:e2e +``` + +Record performance against the production build; development checks distort +absolute timings. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------- | -------------------------------------------------------------------------------------------------- | +| `src/feed/` | Market model, instrument universe, feed config, immutable update logic, and Solid feed controller. | +| `src/feed/worker/` | Protocol, deterministic market engine, and module worker. | +| `src/benchmark/` | Benchmark monitor and the controller that exposes benchmark signals/actions. | +| `src/shell/` | Contexts, viewport shell, header, metrics, configurator, diagnostics, and selected instrument. | +| `src/table/table-config/` | Declarative columns, named cell renderers, component props, and quote value calculations. | +| `src/table/` | Table model primitive, view components, interactions, layout, and virtualization. | +| `src/App.tsx` | Creates feed/benchmark controllers and composes their providers with the table. | + +`createMarketFeedController` and `createTradingBenchmarkController` are +separate. The feed owns worker state and quotes; the benchmark observes feed +events and owns only diagnostic/view state. Solid contexts provide controller +objects, and consumers read individual accessors rather than one global state +snapshot. + +## Feed and worker pipeline + +The initial setup is 100 instruments, 10K generated samples/s, 20 ms delivery, +enabled intraday charts, and 16 ms chart sampling. + +Mutable quotes never leave the worker. A deterministic generator and a 16 ms +budget loop create samples, while a row-indexed `Map` coalesces repeated changes. +A separate timer publishes the latest unique updates. On the main thread the +outer quote array is new, changed rows are new, and untouched rows keep their +references. History arrays change only at their own sampling cadence. Session +IDs prevent stale batches from an old configuration from being applied. + +- **Synthetic quote workload** is generated worker samples/s, not messages or + framework updates. +- **Worker delivery interval** controls coalesced messages; 20 ms targets about + 50 messages/s. +- **Row updates** is the unique immutable rows applied by a message. +- **Message samples** is the worker work represented by the latest message. + +The 25K burst immediately generates and flushes an intentionally heavy batch. +The worker is a local stand-in for an upstream stream; network latency is not +part of the test. + +## Solid table architecture + +The table has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart. It supports sorting/filtering, on-change resizing, +double-click reset, drag ordering, row selection, drag cell ranges, keyboard +navigation, and component-based Price/Move/Percent/Sparkline cells. + +The table reads the feed's quote accessor directly from context; `App` does not +subscribe to or forward quote snapshots. `TradingTable` is only the composition +root for `createTradingTableModel`, row virtualization, layout and commit +effects, plus `TradingTableHeader`, `TradingTableBody`, and +`TradingTableFooter`. Stable instrument IDs back `getRowId`. Full-DOM rows use +Solid's position-owned `` scopes and a dedicated `TradingTableRow` +boundary. Each position receives the current TanStack `Row`. One row-level memo +keeps its render cells while the row ID, column configuration, and cell order +are unchanged. A small Solid context exposes an `Accessor` for that +row, so named renderers and their component props update without replacing the +mounted `FlexRender`. It is a normal accessor, not a proxy or mutable registry. + +Row and cell DOM attributes are written directly in JSX. Every cell slot also +receives the current row-model cell at the same visible-column index; selection +edges, focus, selected state, and roving tabindex are computed from that current +cell. This avoids stale selection geometry without `getAllCellsByColumnId()` or +per-cell row/column ID memos. Grid-level pointer interaction remains delegated +to the table body. + +`` is intentionally not used for the immutable TanStack row model. Solid +keys `` by item identity, while TanStack creates new `Row` objects after a +new data array. Stable `getRowId` values preserve table state, but do not change +Solid's identity comparison; `` would therefore treat every new row object +as a replacement. `` keeps the DOM/component slot mounted while replacing +the current `Row` and selection `Cell` props at that position. The separate +render-cell memo changes only for a new row ID, column definition/order, or row +moving into the slot, so quote snapshots update component props without +remounting `FlexRender`. Sorting updates the affected positions as expected. + +The column configuration is memoized by renderer mode. Changing Stable to A/B +therefore refreshes the column definition once, while normal quote batches do +not rebuild columns. `trading-columns.tsx` contains only the declarative schema; +named renderers, component prop interfaces, and market calculations live in +focused colocated modules. In A/B mode the Move renderer selects the direction +component when that row changes, intentionally testing mount/unmount churn. + +`createTradingGridSelectionHandlers` creates one delegated body handler object +and resolves cells from `composedPath()` plus identity data attributes. No +pointer listener is added per cell, and hover remains CSS-only. Header drag, +grid attributes, and body attributes use focused reusable prop factories; the +hot row/cell path stays as direct JSX. Column dimensions live in CSS variables +and update only for column sizing/order; a `ResizeObserver` performs the initial +fit until the user resizes manually. + +## Virtualization + +- Below 200 rows, automatic mode chooses Full DOM, but Virtual is selectable. +- From 200 through 1,499 rows, automatic mode chooses TanStack Virtual and the + user may still choose Full DOM. +- At 1,500 rows or more, Virtual is forced and the control is disabled. + +`createVirtualizer` uses 32 px row estimates, 10-row overscan, stable row IDs, +transformed rows, and a spacer body. Its range drives the current-row footer. +Both rendering paths use `content-visibility: auto`; in Full DOM it can skip +some browser rendering but does not avoid creating every Solid row/cell. + +## Performance decisions + +- market work and coalescing happen before crossing to the main thread; +- immutable snapshots preserve unchanged row/history references; +- fine-grained signals avoid broad shell or root invalidation; +- position-owned rows/cells preserve DOM slots and render-cell identity while + an accessor updates the current immutable quote; +- delegated grid interaction avoids per-cell handlers; +- CSS variables isolate width changes from quote updates; +- dynamic component churn and sparkline frequency are explicit controls; +- virtualization limits mounted DOM for larger data; +- metrics publish at a lower cadence than feed updates. + +A new outer array is part of the immutable contract. Structural sharing helps +cell rendering, but table sorting/filtering can still rebuild a row model when +the data accessor changes. + +## Diagnostics and interpretation + +The sidebar starts with four cross-framework health signals: estimated rAF +callbacks/s over one second, average snapshot-to-DOM-commit latency over three +seconds, long animation frames accumulated since reset, and throughput as +changed rows/s plus applied snapshots/s. “Changed rows” is deduplicated within +each snapshot; the same instrument can count again in a later snapshot. The +advanced diagnostics retain worker samples/messages, DOM commits, a rolling +10-second p95/max commit latency, slow commits, lifecycle/execution rates, DOM +mutation records, and optional heap information. + +The frame figure is deliberately labeled estimated: it counts this page's rAF +callbacks, is capped by the display refresh rate, and falls when a tab is +throttled. It is a portable responsiveness signal, not compositor-presented +FPS. + +Solid applies reactive DOM writes synchronously, but the quote-tracking effect +queues one coalesced microtask before closing a pending commit measurement. This +prevents an effect scheduled early in the reactive graph from reporting a +commit before child bindings have settled. User Timing timeline entries are +sampled at one in 20 commits; the in-memory latency calculation still records +every commit. + +Renderer callbacks are not equivalent to DOM mutations. With this example's +immutable outer array, TanStack can produce new row/cell instances, but the +row-ID equality boundary keeps `FlexRender` stable across quote snapshots. +Column mode/order changes, sorting, and virtual-slot reuse replace the relevant +render cell. A/B mode additionally replaces the direction component when the +sign changes. A rising heap during +component swapping is not proof of a leak without stable post-GC retention. +The heap value is Chromium-only, represents the current GC-sensitive JS heap, +and is not a retained-size measurement. The DOM rate counts `MutationRecord` +objects, not individual browser operations; records may be coalesced. Its +observer watches text/child changes and only `class`/`style` attributes to +reduce, but not eliminate, observer overhead. Non-feed text/child changes and +interaction-driven `class`/`style` changes (including virtual scrolling) are +included, so do not interpret the rate as feed-only work. Use a production +build, identical controls, Chrome Performance, and Solid DevTools for meaningful +comparisons. + +## Standalone policy + +This directory intentionally owns copies of the feed, worker, instruments, +benchmark code, UI, and styles. That makes it independent and StackBlitz-ready; +shared code and explanatory README sections are duplicated across adapters by +design. + +The workspace resolves the pinned `@tanstack/solid-table` dependency to the +local adapter package while keeping the manifest release-like. diff --git a/examples/solid/realtime-trading/index.html b/examples/solid/realtime-trading/index.html new file mode 100644 index 0000000000..bddd49c0fd --- /dev/null +++ b/examples/solid/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + + + + Solid Real-time Trading flexRender Lab + + +
+ + + diff --git a/examples/solid/realtime-trading/package.json b/examples/solid/realtime-trading/package.json new file mode 100644 index 0000000000..c5ba2fea1d --- /dev/null +++ b/examples/solid/realtime-trading/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-solid-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/solid-table": "9.1.2", + "@tanstack/solid-virtual": "^3.13.36", + "solid-js": "^1.9.14" + }, + "devDependencies": { + "typescript": "6.0.3", + "vite": "^8.2.0", + "vite-plugin-solid": "^2.11.14" + } +} diff --git a/examples/solid/realtime-trading/src/App.tsx b/examples/solid/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..1dfc26f920 --- /dev/null +++ b/examples/solid/realtime-trading/src/App.tsx @@ -0,0 +1,17 @@ +import { createTradingBenchmarkController } from './benchmark/trading-benchmark-controller' +import { createMarketFeedController } from './feed/market-feed-controller' +import { TradingShell } from './shell/TradingShell' +import { TradingShellProvider } from './shell/trading-shell-context' +import { TradingTable } from './table/trading-table' + +export default function App() { + const feed = createMarketFeedController() + const controller = createTradingBenchmarkController(feed) + return ( + + + + + + ) +} diff --git a/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..02925f3da5 --- /dev/null +++ b/examples/solid/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,383 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, +} + +const userTiming = { entryCount: 0, measureCalls: 0 } + +function recordCommitMeasure(start: number, end: number): void { + userTiming.measureCalls++ + if (userTiming.measureCalls % 20 !== 0) return + try { + performance.measure('market-update-to-dom-commit', { start, end }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + pendingMutationStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + } + + markCommitPending(): void { + this.#runtime.pendingMutationStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingMutationStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingMutationStartedAt + runtime.commitLatencySamples.push({ recordedAt: commitEndedAt, duration }) + if (duration > 16.7) runtime.slowCommitCount++ + recordCommitMeasure(runtime.pendingMutationStartedAt, commitEndedAt) + runtime.pendingMutationStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingMutationStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/solid/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/solid/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..32a8664822 --- /dev/null +++ b/examples/solid/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,147 @@ +import { createMemo, createSignal, onCleanup, onMount } from 'solid-js' +import { TRADING_COLUMN_COUNT } from '../table/table-config/trading-columns' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/table-config/trading-columns' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export function createTradingBenchmarkController(feed: MarketFeedController) { + const [rendererMode, setRendererMode] = createSignal('stable') + const [requestedVirtualScrollMode, setRequestedVirtualScrollMode] = + createSignal('auto') + const [renderedRowCount, setRenderedRowCount] = createSignal(0) + const [selectedSymbol, setSelectedSymbol] = createSignal(null) + const [metrics, setMetrics] = createSignal(initialMetrics) + const monitor = new BenchmarkMonitor() + const runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + mutationObserver: null as MutationObserver | null, + } + + const stopObservingFeed = feed.observe({ + messageReceived: () => monitor.recordWorkerMessage(), + mutationStarted: () => monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => monitor.recordDomCommit(), + }) + + const observeTableMutations = (): void => { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) return + + monitor.resetDomMutations() + runtime.mutationObserver = new MutationObserver((records) => { + monitor.recordDomMutations(records.length) + }) + runtime.mutationObserver.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + } + + const benchmarkFrame = (now: number): void => { + monitor.recordAnimationFrame(now) + if (monitor.shouldPublish(now)) { + setMetrics(monitor.publish(now)) + } + runtime.animationFrameId = requestAnimationFrame(benchmarkFrame) + } + + onMount(() => { + observeTableMutations() + if (longAnimationFramesSupported) { + runtime.longAnimationFrameObserver = new PerformanceObserver( + (entries) => { + for (const entry of entries.getEntries()) { + monitor.recordLongAnimationFrame(entry.duration, entry.startTime) + } + }, + ) + runtime.longAnimationFrameObserver.observe({ + type: 'long-animation-frame', + }) + } + runtime.animationFrameId = requestAnimationFrame(benchmarkFrame) + }) + + onCleanup(() => { + stopObservingFeed() + cancelAnimationFrame(runtime.animationFrameId) + runtime.longAnimationFrameObserver?.disconnect() + runtime.mutationObserver?.disconnect() + }) + + const resetViewState = (): void => { + setSelectedSymbol(null) + } + + const actions = { + resetViewState, + setRendererMode, + setVirtualScrollEnabled(enabled: boolean): void { + if (feed.state.instrumentCount() >= FORCED_VIRTUALIZATION_ROW_COUNT) + return + setRequestedVirtualScrollMode(enabled ? 'tanstack' : 'none') + }, + setRenderedRowCount, + selectSymbol: setSelectedSymbol, + resetMarket(): void { + monitor.reset() + resetViewState() + setMetrics({ ...initialMetrics }) + feed.actions.reset() + }, + } + + const virtualScrollForced = createMemo( + () => feed.state.instrumentCount() >= FORCED_VIRTUALIZATION_ROW_COUNT, + ) + const virtualScrollMode = createMemo(() => + resolveVirtualScrollMode( + requestedVirtualScrollMode(), + feed.state.instrumentCount(), + ), + ) + const mountedCells = createMemo( + () => renderedRowCount() * TRADING_COLUMN_COUNT, + ) + const liveComponents = createMemo( + () => metrics().componentsCreated - metrics().componentsDestroyed, + ) + + return { + feed, + state: { + rendererMode, + requestedVirtualScrollMode, + virtualScrollForced, + virtualScrollMode, + renderedRowCount, + selectedSymbol, + metrics, + mountedCells, + liveComponents, + }, + actions, + monitor, + } +} + +export type TradingBenchmarkController = ReturnType< + typeof createTradingBenchmarkController +> diff --git a/examples/solid/realtime-trading/src/feed/feed-sample-rates.ts b/examples/solid/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/solid/realtime-trading/src/feed/market-data.ts b/examples/solid/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/solid/realtime-trading/src/feed/market-feed-config.ts b/examples/solid/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/solid/realtime-trading/src/feed/market-feed-controller.ts b/examples/solid/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..4c93721c8c --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,199 @@ +import { createSignal, onCleanup, onMount } from 'solid-js' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export function createMarketFeedController() { + const [workerReady, setWorkerReady] = createSignal(false) + const [running, setRunning] = createSignal(true) + const [instrumentCount, setInstrumentCount] = createSignal( + initialMarketFeedConfig.instrumentCount, + ) + const [targetTicksPerSecond, setTargetTicksPerSecond] = createSignal( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + const [publishIntervalMs, setPublishIntervalMsState] = createSignal( + initialMarketFeedConfig.publishIntervalMs, + ) + const [updateSparklines, setUpdateSparklines] = createSignal( + initialMarketFeedConfig.updateSparklines, + ) + const [sparklineSampleIntervalMs, setSparklineSampleIntervalMs] = + createSignal(initialMarketFeedConfig.sparklineSampleIntervalMs) + const [quotes, setQuotes] = createSignal>([]) + const observers = new Set() + const runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + quoteIndexBySymbol: new Map(), + } + + const post = (command: MarketFeedCommand): void => + runtime.worker?.postMessage(command) + + const startMutation = (): void => { + runtime.renderPending = true + for (const observer of observers) { + observer.mutationStarted?.() + } + } + + const completeRender = (): void => { + if (!runtime.renderPending) return + + runtime.renderPending = false + for (const observer of observers) { + observer.renderCommitted?.() + } + } + + const handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + runtime.feedSessionId = data.sessionId + startMutation() + const nextQuotes = hydrateMarketQuotes(data.quotes) + runtime.quoteIndexBySymbol = new Map( + nextQuotes.map((quote, index) => [quote.symbol, index]), + ) + setQuotes(nextQuotes) + setWorkerReady(true) + return + } + + if (data.sessionId !== runtime.feedSessionId) return + + for (const observer of observers) { + observer.messageReceived?.() + } + startMutation() + setQuotes((currentQuotes) => + applyMarketUpdates(currentQuotes, data.updates), + ) + const batch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of observers) { + observer.batchApplied?.(batch) + } + } + + const handleWorkerError = (error: ErrorEvent): void => { + setWorkerReady(false) + setRunning(false) + console.error('Market feed worker failed', error) + } + + const reset = (): void => { + setWorkerReady(false) + post({ type: 'reset', rowCount: instrumentCount() }) + } + + onMount(() => { + runtime.worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + runtime.worker.addEventListener('message', handleWorkerMessage) + runtime.worker.addEventListener('error', handleWorkerError) + post({ + type: 'start', + rowCount: instrumentCount(), + running: running(), + ticksPerSecond: targetTicksPerSecond(), + publishIntervalMs: publishIntervalMs(), + updateSparklines: updateSparklines(), + sparklineSampleIntervalMs: sparklineSampleIntervalMs(), + }) + }) + + onCleanup(() => { + runtime.worker?.removeEventListener('message', handleWorkerMessage) + runtime.worker?.removeEventListener('error', handleWorkerError) + runtime.worker?.terminate() + runtime.worker = null + observers.clear() + }) + + const actions = { + toggle(): void { + const nextRunning = !running() + setRunning(nextRunning) + post({ type: 'set-running', running: nextRunning }) + }, + setInstrumentCount(count: number): void { + setInstrumentCount(count) + reset() + }, + setTargetRate(rate: number): void { + const sampleRate = normalizeFeedSampleRate(rate) + setTargetTicksPerSecond(sampleRate) + post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval(intervalMs: number): void { + setPublishIntervalMsState(intervalMs) + post({ type: 'set-publish-interval', intervalMs }) + }, + setSparklineUpdates(enabled: boolean): void { + setUpdateSparklines(enabled) + post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval(intervalMs: number): void { + setSparklineSampleIntervalMs(intervalMs) + post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst(): void { + post({ type: 'burst', tickCount: 25_000 }) + }, + reset, + } + + return { + state: { + workerReady, + running, + instrumentCount, + targetTicksPerSecond, + publishIntervalMs, + updateSparklines, + sparklineSampleIntervalMs, + quotes, + }, + actions, + observe(observer: MarketFeedObserver): () => void { + observers.add(observer) + return () => observers.delete(observer) + }, + getQuoteBySymbol(symbol: string | null): MarketQuote | null { + if (symbol === null) return null + + const index = runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes()[index] ?? null) + }, + completeRender, + } +} + +export type MarketFeedController = ReturnType diff --git a/examples/solid/realtime-trading/src/feed/market-instruments.ts b/examples/solid/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/solid/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/solid/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/solid/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/solid/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/solid/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/solid/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/solid/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/solid/realtime-trading/src/index.css b/examples/solid/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/solid/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/solid/realtime-trading/src/index.tsx b/examples/solid/realtime-trading/src/index.tsx new file mode 100644 index 0000000000..3b0ef7f19f --- /dev/null +++ b/examples/solid/realtime-trading/src/index.tsx @@ -0,0 +1,8 @@ +import { render } from 'solid-js/web' +import App from './App' +import './index.css' + +const rootElement = document.getElementById('root') +if (!rootElement) throw new Error('Failed to find the root element') + +render(() => , rootElement) diff --git a/examples/solid/realtime-trading/src/shell/TradingShell.tsx b/examples/solid/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..8fdf1b60a9 --- /dev/null +++ b/examples/solid/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,601 @@ +import { For, Show, createMemo, createSignal } from 'solid-js' +import { longAnimationFramesSupported } from '../benchmark/benchmark-monitor' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + useMarketFeedController, + useTradingShellController, +} from './trading-shell-context' +import { configuratorOptions } from './configurator-options' +import type { JSX } from 'solid-js' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) +export function TradingShell(props: { children: JSX.Element }) { + const [sidebarOpen, setSidebarOpen] = createSignal(true) + const toggleSidebar = () => setSidebarOpen((open) => !open) + + return ( +
+
+ + + + + +
+ +
+ {props.children} +
+ + +
+ ) +} + +function AppHeader(props: { + sidebarOpen: boolean + onSidebarToggle: () => void +}) { + const { workerReady, running } = useMarketFeedController().state + return ( +
+
+ MARKET MONITOR +
+
+ + + +
+
+ ) +} + +function MarketStatusbar() { + const { metrics, mountedCells, liveComponents } = + useTradingShellController().state + return ( +
+ + MESSAGE SAMPLES{' '} + {formatInteger(metrics().lastBatchSize)} + + + CHANGED ROWS {formatInteger(metrics().lastUpdateCount)} + + + HOSTS {formatInteger(mountedCells())} + + + COMPONENTS {formatInteger(liveComponents())} + +
+ ) +} + +function Configurator() { + const { state, actions } = useTradingShellController() + const feed = useMarketFeedController() + const { rendererMode, virtualScrollForced, virtualScrollMode } = state + const { + running, + instrumentCount, + targetTicksPerSecond, + publishIntervalMs, + updateSparklines, + sparklineSampleIntervalMs, + } = feed.state + const { + resetViewState, + setRendererMode, + setVirtualScrollEnabled, + resetMarket, + } = actions + const { + toggle, + setInstrumentCount, + setTargetRate, + setPublishInterval, + setSparklineUpdates, + setSparklineSampleInterval, + runBurst, + } = feed.actions + return ( + + ) +} + +function MetricsStrip() { + const { metrics } = useTradingShellController().state + return ( +
+

LIVE HEALTH

+
+ FRAME RATE (EST.) + + {metrics().rafCallbacksPerSecond.toFixed(1)} + + rAF callbacks/s · rolling 1 s +
+
+ AVG COMMIT + + {formatMs(metrics().averageCommitLatencyMs)} + + snapshot → DOM · rolling 3 s +
+
+ LONG FRAMES + + N/A + unsupported by this browser + + } + > + 0, + }} + > + {metrics().longAnimationFrames} + + + since reset · worst {formatMs(metrics().worstLongAnimationFrameMs)} + + +
+
+ THROUGHPUT + + {formatRate(metrics().rowUpdatesPerSecond)} rows/s + + + {metrics().stateApplicationsPerSecond.toFixed(1)} + snapshots/s · rows deduplicated per snapshot + +
+
+ ) +} + +function Diagnostics() { + const { metrics, mountedCells, liveComponents } = + useTradingShellController().state + return ( +
+

DIAGNOSTICS

+
+
+
Worker samples / s
+
+ {formatRate(metrics().actualTicksPerSecond)} +
+
+
+
Worker messages / s
+
+ {metrics().workerMessagesPerSecond.toFixed(1)} +
+
+
+
Changed rows / s
+
+ {formatRate(metrics().rowUpdatesPerSecond)} +
+
+
+
Snapshots applied / s
+
+ {metrics().stateApplicationsPerSecond.toFixed(1)} +
+
+
+
DOM commits / s
+
+ {metrics().tableCommitsPerSecond.toFixed(1)} +
+
+
+
P95 / max commit latency
+
+ {formatMs(metrics().p95CommitLatencyMs)} /{' '} + {formatMs(metrics().maxCommitLatencyMs)} +
+
+
+
Mounted cells
+
{formatInteger(mountedCells())}
+
+
+
Live components
+
{formatInteger(liveComponents())}
+
+
+
Created / destroyed
+
+ {formatInteger(metrics().componentsCreated)} /{' '} + {formatInteger(metrics().componentsDestroyed)} +
+
+
+
Renderer callbacks / s
+
+ {formatRate(metrics().cellRendererCallsPerSecond)} +
+
+
+
Component executions / s
+
+ {formatRate(metrics().componentRenderCallsPerSecond)} +
+
+
+
Executions by component / s
+
+ {formatInvocationRates(metrics().componentRenderRates)} +
+
+
+
Callbacks by column / s
+
+ {formatInvocationRates(metrics().cellRendererRates)} +
+
+
+
Observed MutationRecords / s
+
+ {formatRate(metrics().domMutationsPerSecond)} +
+
+
+
Worker messages
+
+ {formatInteger(metrics().workerMessages)} +
+
+
+
Worker-coalesced updates / s
+
+ {formatRate(metrics().supersededUpdatesPerSecond)} +
+
+
+
Last message samples / updated rows
+
+ {formatInteger(metrics().lastBatchSize)} /{' '} + {formatInteger(metrics().lastUpdateCount)} +
+
+
+
Commits > 16.7 ms
+
{metrics().slowCommits}
+
+
+
Long animation frames
+
+ {longAnimationFramesSupported + ? formatInteger(metrics().longAnimationFrames) + : 'Unsupported'} +
+
+
+
JS heap (GC-sensitive)
+
+ {metrics().heapMb === null + ? 'N/A' + : `${metrics().heapMb?.toFixed(1)} MB`} +
+
+
+
+ ) +} + +function SelectedInstrument() { + const feed = useMarketFeedController() + const { selectedSymbol } = useTradingShellController().state + const selectedQuote = createMemo(() => + feed.getQuoteBySymbol(selectedSymbol()), + ) + return ( +
+

SELECTED INSTRUMENT

+ + Click or begin a cell selection in any row to inspect its + instrument. +

+ } + > + {(quote) => ( + <> +
+
+ {quote().symbol} + {quote().company} +
+ {quote().venue} +
+
+
+
Last
+
{quote().price.toFixed(2)}
+
+
+
Bid / ask
+
+ {quote().bid.toFixed(2)} / {quote().ask.toFixed(2)} +
+
+
+ + )} +
+
+ ) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['componentRenderRates'], +): string { + const activeRates = rates + .filter((rate) => rate.callsPerSecond > 0) + .sort((left, right) => right.callsPerSecond - left.callsPerSecond) + return activeRates.length === 0 + ? 'No calls in sample' + : activeRates + .map((rate) => `${rate.name} ${formatRate(rate.callsPerSecond)}`) + .join(' · ') +} diff --git a/examples/solid/realtime-trading/src/shell/configurator-options.ts b/examples/solid/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/solid/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx b/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx new file mode 100644 index 0000000000..906f35e52e --- /dev/null +++ b/examples/solid/realtime-trading/src/shell/trading-shell-context.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext } from 'solid-js' +import type { JSX } from 'solid-js' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +const TradingShellContext = createContext() +const MarketFeedContext = createContext() + +export function MarketFeedProvider(props: { + controller: MarketFeedController + children: JSX.Element +}) { + return ( + + {props.children} + + ) +} + +export function TradingShellProvider(props: { + controller: TradingBenchmarkController + children: JSX.Element +}) { + return ( + + + {props.children} + + + ) +} + +export function useMarketFeedController(): MarketFeedController { + const controller = useContext(MarketFeedContext) + if (!controller) { + throw new Error( + 'Market data consumers must be rendered inside MarketFeedProvider', + ) + } + return controller +} + +export function useTradingShellController(): TradingBenchmarkController { + const controller = useContext(TradingShellContext) + if (!controller) { + throw new Error( + 'Trading shell components must be rendered inside TradingShellProvider', + ) + } + return controller +} diff --git a/examples/solid/realtime-trading/src/table/create-column-drag.ts b/examples/solid/realtime-trading/src/table/create-column-drag.ts new file mode 100644 index 0000000000..2acf3b96ba --- /dev/null +++ b/examples/solid/realtime-trading/src/table/create-column-drag.ts @@ -0,0 +1,66 @@ +import { createSignal } from 'solid-js' +import { reorderColumnIds } from './table-interactions' +import type { JSX } from 'solid-js' +import type { TradingTableInstance } from './trading-table-features' + +export function createColumnDrag(table: TradingTableInstance) { + const [sourceColumnId, setSourceColumnId] = createSignal(null) + const [targetColumnId, setTargetColumnId] = createSignal(null) + + const clear = (): void => { + setSourceColumnId(null) + setTargetColumnId(null) + } + + const createDropZoneProps = ( + columnId: string, + ): Pick => ({ + onDragOver(event) { + event.preventDefault() + setTargetColumnId(sourceColumnId() === columnId ? null : columnId) + }, + onDrop(event) { + event.preventDefault() + const sourceId = + event.dataTransfer?.getData('text/plain') || sourceColumnId() + if (sourceId) { + table.setColumnOrder( + reorderColumnIds( + table.getVisibleLeafColumns().map((column) => column.id), + sourceId, + columnId, + ), + ) + } + clear() + }, + }) + + const createHandleProps = ( + columnId: string, + ): Pick< + JSX.IntrinsicElements['button'], + 'draggable' | 'aria-label' | 'onDragStart' | 'onDragEnd' + > => ({ + draggable: true, + 'aria-label': `Move ${columnId} column`, + onDragStart(event) { + setSourceColumnId(columnId) + const dataTransfer = event.dataTransfer + if (!dataTransfer) return + + dataTransfer.effectAllowed = 'move' + dataTransfer.setData('text/plain', columnId) + }, + onDragEnd: clear, + }) + + return { + createDropZoneProps, + createHandleProps, + isDragging: (columnId: string) => sourceColumnId() === columnId, + isDropTarget: (columnId: string) => targetColumnId() === columnId, + } +} + +export type ColumnDrag = ReturnType diff --git a/examples/solid/realtime-trading/src/table/create-trading-table-model.ts b/examples/solid/realtime-trading/src/table/create-trading-table-model.ts new file mode 100644 index 0000000000..0c890ae800 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/create-trading-table-model.ts @@ -0,0 +1,60 @@ +import { createTable } from '@tanstack/solid-table' +import { createMemo } from 'solid-js' +import { createTradingColumns } from './table-config/trading-columns' +import { tradingTableFeatures } from './trading-table-features' +import type { Accessor } from 'solid-js' +import type { MarketQuote } from '../feed/market-data' +import type { RendererMode } from './table-config/trading-columns' + +export interface CreateTradingTableModelOptions { + quotes: Accessor> + rendererMode: Accessor + onSelectSymbol: (symbol: string) => void +} + +/** + * Owns the TanStack Table model and the computations derived from table atoms. + * Consumers can subscribe to rows, columns, or layout independently. + */ +export function createTradingTableModel( + options: CreateTradingTableModelOptions, +) { + const columns = createMemo(() => + createTradingColumns({ + rendererMode: options.rendererMode(), + onSelectSymbol: options.onSelectSymbol, + }), + ) + const table = createTable({ + key: 'solid-realtime-trading', + features: tradingTableFeatures, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + get columns() { + return columns() + }, + get data() { + return options.quotes() + }, + getRowId: (row) => row.id, + }) + const rows = createMemo(() => table.getRowModel().rows) + const tableStyle = createMemo(() => { + void table.atoms.columnSizing.get() + void table.atoms.columnOrder.get() + + const style: Record = { + width: `${table.getTotalSize()}px`, + } + for (const header of table.getFlatHeaders()) { + style[`--header-${header.id}-size`] = `${header.getSize()}` + style[`--col-${header.column.id}-size`] = `${header.column.getSize()}` + } + return style + }) + + return { columns, rows, table, tableStyle } +} + +export type TradingTableModel = ReturnType diff --git a/examples/solid/realtime-trading/src/table/jsx-attributes.ts b/examples/solid/realtime-trading/src/table/jsx-attributes.ts new file mode 100644 index 0000000000..b67d9c7ad2 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/jsx-attributes.ts @@ -0,0 +1,4 @@ +/** Solid's intrinsic element types intentionally omit open-ended data-* keys. */ +export type WithDataAttributes = Attributes & { + [attribute: `data-${string}`]: string | number | boolean | undefined +} diff --git a/examples/solid/realtime-trading/src/table/table-config/market-quote-values.ts b/examples/solid/realtime-trading/src/table/table-config/market-quote-values.ts new file mode 100644 index 0000000000..4e2b5a21b1 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-config/market-quote-values.ts @@ -0,0 +1,11 @@ +import type { MarketQuote } from '../../feed/market-data' + +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/solid/realtime-trading/src/table/table-config/quote-cell-props.ts b/examples/solid/realtime-trading/src/table/table-config/quote-cell-props.ts new file mode 100644 index 0000000000..eb0e12d911 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-config/quote-cell-props.ts @@ -0,0 +1,31 @@ +export interface PriceCellProps { + price: number + move: number + onSelect: () => void +} + +export interface MoveCellProps { + move: number +} + +export interface PercentChangeCellProps { + value: number +} + +export interface SpreadCellProps { + bid: number + ask: number +} + +export interface DepthCellProps { + bidSize: number + askSize: number +} + +export interface QuoteAgeCellProps { + ageMs: number +} + +export interface SparklineCellProps { + values: ReadonlyArray +} diff --git a/examples/solid/realtime-trading/src/table/table-config/quote-cell-renderers.tsx b/examples/solid/realtime-trading/src/table/table-config/quote-cell-renderers.tsx new file mode 100644 index 0000000000..c83c2bf561 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-config/quote-cell-renderers.tsx @@ -0,0 +1,119 @@ +import { Show } from 'solid-js' +import { useTradingRowData } from '../trading-row-data-context' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import { getDayChange, getDayChangePercent } from './market-quote-values' + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export function createPriceCellRenderer( + onSelectSymbol: (symbol: string) => void, +) { + return function PriceCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Last', () => ( + onSelectSymbol(quote().symbol)} + /> + )) + } +} + +export function StableMoveCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Change', () => ( + + )) +} + +export function SwappingMoveCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Change', () => ( + = 0} + fallback={} + > + + + )) +} + +export function PercentChangeCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('ChangePercent', () => ( + + )) +} + +export function SparklineCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Intraday', () => ( + + )) +} + +export function MarketCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Market', () => <>{quote().venue}) +} + +export function NameCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Name', () => <>{quote().company}) +} + +export function SymbolCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Symbol', () => <>{quote().symbol}) +} + +export function BidCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Bid', () => <>{quote().bid.toFixed(2)}) +} + +export function BidVolumeCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('BidVolume', () => ( + <>{compactFormatter.format(quote().bidSize)} + )) +} + +export function AskCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Ask', () => <>{quote().ask.toFixed(2)}) +} + +export function AskVolumeCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('AskVolume', () => ( + <>{compactFormatter.format(quote().askSize)} + )) +} + +export function OpenCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Open', () => <>{quote().open.toFixed(2)}) +} + +export function HighCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('High', () => <>{quote().high.toFixed(2)}) +} + +export function LowCellRenderer() { + const quote = useTradingRowData() + return recordCellRender('Low', () => <>{quote().low.toFixed(2)}) +} diff --git a/examples/solid/realtime-trading/src/table/table-config/quote-cells.tsx b/examples/solid/realtime-trading/src/table/table-config/quote-cells.tsx new file mode 100644 index 0000000000..510246103b --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-config/quote-cells.tsx @@ -0,0 +1,245 @@ +import { createMemo, onCleanup, onMount } from 'solid-js' +import type { + DepthCellProps, + MoveCellProps, + PercentChangeCellProps, + PriceCellProps, + QuoteAgeCellProps, + SparklineCellProps, + SpreadCellProps, +} from './quote-cell-props' + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SpreadCell', + 'DepthCell', + 'QuoteAgeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender( + name: QuoteCellRendererName, + value: () => T, +): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value() +} + +function trackLifecycle(componentName: QuoteComponentName): void { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + onMount(() => { + quoteCellLifecycle.created++ + }) + onCleanup(() => { + quoteCellLifecycle.destroyed++ + }) +} + +export function PriceCell(props: PriceCellProps) { + trackLifecycle('PriceCell') + return ( + + ) +} + +export function StableMoveCell(props: MoveCellProps) { + trackLifecycle('StableMoveCell') + return ( + = 0, + 'quote-down': props.move < 0, + }} + > + {formatSigned(props.move)} + + ) +} + +export function UpMoveCell(props: MoveCellProps) { + trackLifecycle('UpMoveCell') + return ▲ {formatSigned(props.move)} +} + +export function DownMoveCell(props: MoveCellProps) { + trackLifecycle('DownMoveCell') + return ▼ {formatSigned(props.move)} +} + +export function PercentChangeCell(props: PercentChangeCellProps) { + trackLifecycle('PercentChangeCell') + return ( + = 0, + 'quote-down': props.value < 0, + }} + > + {props.value >= 0 ? '+' : ''} + {props.value.toFixed(2)}% + + ) +} + +export function SpreadCell(props: SpreadCellProps) { + trackLifecycle('SpreadCell') + const spread = createMemo(() => Math.max(0, props.ask - props.bid)) + const basisPoints = createMemo(() => { + const midpoint = (props.bid + props.ask) / 2 + return midpoint === 0 ? 0 : (spread() / midpoint) * 10_000 + }) + + return ( + = 4 }}> + {spread().toFixed(2)} + {basisPoints().toFixed(1)} bp + + ) +} + +export function DepthCell(props: DepthCellProps) { + trackLifecycle('DepthCell') + const bidShare = createMemo(() => { + const total = props.bidSize + props.askSize + return total === 0 ? 50 : (props.bidSize / total) * 100 + }) + + return ( +
+ + + + {compactNumber.format(props.bidSize)} + {compactNumber.format(props.askSize)} + +
+ ) +} + +export function QuoteAgeCell(props: QuoteAgeCellProps) { + trackLifecycle('QuoteAgeCell') + return ( + = 500, + 'quote-age-stale': props.ageMs >= 1_500, + }} + > + {props.ageMs < 1_000 + ? `${Math.round(props.ageMs)} ms` + : `${(props.ageMs / 1_000).toFixed(1)} s`} + + ) +} + +export function SparklineCell(props: SparklineCellProps) { + trackLifecycle('SparklineCell') + const rising = createMemo( + () => (props.values.at(-1) ?? 0) >= (props.values[0] ?? 0), + ) + const points = createMemo(() => { + const { min, max } = findRange(props.values) + const range = max - min || 1 + const denominator = Math.max(1, props.values.length - 1) + return props.values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + }) + + return ( + + + + ) +} + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} + +function findRange(values: ReadonlyArray): { + min: number + max: number +} { + const first = values[0] ?? 0 + return values.reduce( + (range, value) => { + range.min = Math.min(range.min, value) + range.max = Math.max(range.max, value) + return range + }, + { min: first, max: first }, + ) +} diff --git a/examples/solid/realtime-trading/src/table/table-config/trading-columns.tsx b/examples/solid/realtime-trading/src/table/table-config/trading-columns.tsx new file mode 100644 index 0000000000..9146575115 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-config/trading-columns.tsx @@ -0,0 +1,183 @@ +import { + AskCellRenderer, + AskVolumeCellRenderer, + BidCellRenderer, + BidVolumeCellRenderer, + HighCellRenderer, + LowCellRenderer, + MarketCellRenderer, + NameCellRenderer, + OpenCellRenderer, + PercentChangeCellRenderer, + SparklineCellRenderer, + StableMoveCellRenderer, + SwappingMoveCellRenderer, + SymbolCellRenderer, + createPriceCellRenderer, +} from './quote-cell-renderers' +import { getDayChange, getDayChangePercent } from './market-quote-values' +import type { JSX } from 'solid-js' +import type { MarketQuote } from '../../feed/market-data' +import type { TradingCellContext } from '../trading-table-features' + +export type RendererMode = 'stable' | 'swap' + +export interface TradingColumnOptions { + rendererMode: RendererMode + onSelectSymbol: (symbol: string) => void +} + +export interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + cell?: (context: TradingCellContext) => JSX.Element +} + +export type TradingColumnSet = Array + +export function createTradingColumns( + options: TradingColumnOptions, +): TradingColumnSet { + const priceCell = createPriceCellRenderer(options.onSelectSymbol) + const changeCell = + options.rendererMode === 'stable' + ? StableMoveCellRenderer + : SwappingMoveCellRenderer + + return [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: MarketCellRenderer, + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: NameCellRenderer, + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + cell: SymbolCellRenderer, + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + cell: priceCell, + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: (row) => getDayChange(row), + cell: changeCell, + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: (row) => getDayChangePercent(row), + cell: PercentChangeCellRenderer, + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: BidCellRenderer, + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: BidVolumeCellRenderer, + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: AskCellRenderer, + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: AskVolumeCellRenderer, + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: OpenCellRenderer, + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: HighCellRenderer, + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: LowCellRenderer, + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: SparklineCellRenderer, + }, + ], + }, + ] +} + +export const TRADING_COLUMN_COUNT = 14 diff --git a/examples/solid/realtime-trading/src/table/table-interactions.ts b/examples/solid/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..6ed8912743 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,194 @@ +import type { JSX } from 'solid-js' + +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +/** + * Creates the only pointer listeners used by the table body. Cells expose just + * identity data; delegated events resolve the current TanStack cell on demand. + */ +export function createTradingGridSelectionHandlers( + table: TradingGridTable, + selectSymbol: (symbol: string) => void, +) { + const runtime = { lastCell: null as HTMLTableCellElement | null } + + return { + onMouseDown(event) { + if (event.button !== 0) return + + const target = findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + runtime.lastCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + }, + onMouseOver(event) { + if ((event.buttons & 1) === 0) { + runtime.lastCell = null + return + } + + const target = findCellTarget(table, event.composedPath()) + if (!target || target.element === runtime.lastCell) return + + runtime.lastCell = target.element + target.cell.getSelectionExtendHandler()(event) + }, + onMouseLeave() { + runtime.lastCell = null + }, + onClick(event) { + const target = findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + }, + } satisfies Pick< + JSX.IntrinsicElements['tbody'], + 'onMouseDown' | 'onMouseOver' | 'onMouseLeave' | 'onClick' + > +} + +function findCellTarget( + table: TradingGridTable, + path: Array, +): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/solid/realtime-trading/src/table/trading-grid-props.ts b/examples/solid/realtime-trading/src/table/trading-grid-props.ts new file mode 100644 index 0000000000..b4cac193e0 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-grid-props.ts @@ -0,0 +1,41 @@ +import { handleCellNavigation } from './table-interactions' +import type { Accessor, JSX } from 'solid-js' +import type { WithDataAttributes } from './jsx-attributes' +import type { TradingTableInstance } from './trading-table-features' + +export interface TradingGridPropsOptions { + table: TradingTableInstance + virtualized: Accessor +} + +export function createTradingGridProps( + options: TradingGridPropsOptions, +): WithDataAttributes { + return { + class: 'table-scroll', + get classList() { + return { 'is-virtualized': options.virtualized() } + }, + 'data-trading-table': true, + tabindex: 0, + onKeyDown: (event) => handleCellNavigation(options.table, event), + } +} + +export function createTradingTableElementProps( + options: TradingGridPropsOptions, + style: Accessor>, +): WithDataAttributes { + return { + class: 'trading-data-grid', + get classList() { + return { 'virtual-table': options.virtualized() } + }, + 'data-testid': 'trading-table', + role: 'grid', + 'aria-multiselectable': true, + get style() { + return style() + }, + } +} diff --git a/examples/solid/realtime-trading/src/table/trading-row-data-context.tsx b/examples/solid/realtime-trading/src/table/trading-row-data-context.tsx new file mode 100644 index 0000000000..593c084a93 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-row-data-context.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext } from 'solid-js' +import type { Accessor, JSX } from 'solid-js' +import type { MarketQuote } from '../feed/market-data' + +export interface TradingRowDataProviderProps { + quote: Accessor + children?: JSX.Element +} + +const TradingRowDataContext = createContext>() + +export function TradingRowDataProvider(props: TradingRowDataProviderProps) { + return ( + + {props.children} + + ) +} + +export function useTradingRowData(): Accessor { + const quote = useContext(TradingRowDataContext) + if (!quote) { + throw new Error('Trading cell renderers require TradingRowDataProvider') + } + return quote +} diff --git a/examples/solid/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/solid/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..85ec4ec247 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,78 @@ +import { createVirtualizer } from '@tanstack/solid-virtual' +import { createEffect, createMemo } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { TradingRow } from './trading-table-features' + +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export interface TradingRowVirtualizationOptions { + rows: Accessor> + scrollElement: Accessor + enabled: Accessor + onRenderedRowCount: (count: number) => void +} + +export function createTradingRowVirtualization( + options: TradingRowVirtualizationOptions, +) { + const virtualizer = createVirtualizer({ + get count() { + return options.rows().length + }, + estimateSize: () => TRADING_ROW_HEIGHT, + getScrollElement: options.scrollElement, + getItemKey: (index) => options.rows()[index]?.id ?? index, + overscan: TRADING_ROW_OVERSCAN, + get enabled() { + return options.enabled() + }, + }) + const virtualRows = virtualizer.getVirtualItems + const visibleRange = createMemo(() => { + void virtualRows() + const rowCount = options.rows().length + const range = virtualizer.range + if (!options.enabled() || rowCount === 0 || range === null) return null + + const lastRowIndex = rowCount - 1 + const start = Math.min(range.startIndex, lastRowIndex) + return { + start, + end: Math.min(Math.max(start, range.endIndex), lastRowIndex), + } + }) + const bodyHeight = createMemo(() => + options.enabled() + ? `${options.rows().length * TRADING_ROW_HEIGHT}px` + : undefined, + ) + + createEffect(() => { + options.onRenderedRowCount( + options.enabled() ? virtualRows().length : options.rows().length, + ) + }) + + return { bodyHeight, virtualizer, virtualRows, visibleRange } +} + +export type TradingRowVirtualization = ReturnType< + typeof createTradingRowVirtualization +> + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/solid/realtime-trading/src/table/trading-table-body.tsx b/examples/solid/realtime-trading/src/table/trading-table-body.tsx new file mode 100644 index 0000000000..3a074634b3 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-body.tsx @@ -0,0 +1,81 @@ +import { Index, Show, createMemo } from 'solid-js' +import { createTradingGridSelectionHandlers } from './table-interactions' +import { TradingTableRow } from './trading-table-row' +import type { Accessor, JSX } from 'solid-js' +import type { TradingColumnSet } from './table-config/trading-columns' +import type { TradingRowVirtualization } from './trading-row-virtualizer' +import type { TradingRow, TradingTableInstance } from './trading-table-features' + +export interface TradingTableBodyProps { + table: TradingTableInstance + rows: Accessor> + columnVersion: Accessor + selectedSymbol: Accessor + virtualized: Accessor + virtualization: TradingRowVirtualization + selectSymbol: (symbol: string) => void +} + +export function TradingTableBody(props: TradingTableBodyProps) { + const bodyProps = createTradingTableBodyProps(props) + + return ( + + }> + + + + ) +} + +function FullTableRows(props: TradingTableBodyProps) { + return ( + + {(row) => ( + + )} + + ) +} + +function VirtualTableRows(props: TradingTableBodyProps) { + return ( + + {(virtualRow) => { + const row = createMemo(() => props.rows()[virtualRow().index]) + return ( + + ) + }} + + ) +} + +function createTradingTableBodyProps( + options: TradingTableBodyProps, +): JSX.IntrinsicElements['tbody'] { + const selectionHandlers = createTradingGridSelectionHandlers( + options.table, + options.selectSymbol, + ) + + return { + ...selectionHandlers, + get classList() { + return { 'virtual-table-body': options.virtualized() } + }, + get style() { + const height = options.virtualization.bodyHeight() + return height === undefined ? undefined : { height } + }, + } +} diff --git a/examples/solid/realtime-trading/src/table/trading-table-effects.ts b/examples/solid/realtime-trading/src/table/trading-table-effects.ts new file mode 100644 index 0000000000..d106818ba1 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-effects.ts @@ -0,0 +1,68 @@ +import { createEffect, onCleanup, onMount } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { TradingTableInstance } from './trading-table-features' + +export function createFeedCommitTracking( + quotes: Accessor, + completeCommit: () => void, +): void { + const runtime = { queued: false, disposed: false } + + createEffect(() => { + quotes() + if (runtime.queued) return + + runtime.queued = true + queueMicrotask(() => { + runtime.queued = false + if (!runtime.disposed) completeCommit() + }) + }) + + onCleanup(() => { + runtime.disposed = true + }) +} + +export function createTableAutoFit( + table: TradingTableInstance, + scrollElement: Accessor, +): void { + const runtime = { manuallyResized: false } + + onMount(() => { + const fitAvailableWidth = (): void => { + const element = scrollElement() + if (!element || runtime.manuallyResized) return + + const currentWidth = table.getTotalSize() + const availableWidth = element.clientWidth + if (availableWidth <= currentWidth + 1 || currentWidth <= 0) return + + const ratio = availableWidth / currentWidth + table.setColumnSizing( + Object.fromEntries( + table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + const resizeObserver = new ResizeObserver(fitAvailableWidth) + const resizingSubscription = table.atoms.columnResizing.subscribe( + (state) => { + if (state.isResizingColumn !== false) { + runtime.manuallyResized = true + } + }, + ) + const element = scrollElement() + if (element) resizeObserver.observe(element) + fitAvailableWidth() + + onCleanup(() => { + resizeObserver.disconnect() + resizingSubscription.unsubscribe() + }) + }) +} diff --git a/examples/solid/realtime-trading/src/table/trading-table-features.ts b/examples/solid/realtime-trading/src/table/trading-table-features.ts new file mode 100644 index 0000000000..0af9b8983b --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-features.ts @@ -0,0 +1,19 @@ +import { + createSortedRowModel, + stockFeatures, + tableFeatures, +} from '@tanstack/solid-table' +import type { Cell, Header, Row, SolidTable } from '@tanstack/solid-table' +import type { MarketQuote } from '../feed/market-data' + +export const tradingTableFeatures = tableFeatures({ + ...stockFeatures, + sortedRowModel: createSortedRowModel(), +}) + +export type TradingTableFeatures = typeof tradingTableFeatures +export type TradingTableInstance = SolidTable +export type TradingRow = Row +export type TradingCell = Cell +export type TradingCellContext = ReturnType +export type TradingHeader = Header diff --git a/examples/solid/realtime-trading/src/table/trading-table-footer.tsx b/examples/solid/realtime-trading/src/table/trading-table-footer.tsx new file mode 100644 index 0000000000..a664a1bb73 --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-footer.tsx @@ -0,0 +1,32 @@ +import { Show } from 'solid-js' +import type { Accessor } from 'solid-js' +import type { TradingRowVirtualization } from './trading-row-virtualizer' +import type { TradingRow, TradingTableInstance } from './trading-table-features' + +export interface TradingTableFooterProps { + table: TradingTableInstance + rows: Accessor> + virtualized: Accessor + virtualization: TradingRowVirtualization +} + +export function TradingTableFooter(props: TradingTableFooterProps) { + return ( + +
+ + TanStack · Total · {props.rows().length} rows ·{' '} + {props.table.getVisibleLeafColumns().length} columns + + + + {(range) => `Current · rows ${range().start}..${range().end}`} + + +
+
+ ) +} diff --git a/examples/solid/realtime-trading/src/table/trading-table-header.tsx b/examples/solid/realtime-trading/src/table/trading-table-header.tsx new file mode 100644 index 0000000000..6a0f1ae86c --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-header.tsx @@ -0,0 +1,166 @@ +import { FlexRender } from '@tanstack/solid-table' +import { For, Show } from 'solid-js' +import { createColumnDrag } from './create-column-drag' +import { sortAriaValue, sortIndicator } from './table-interactions' +import type { JSX } from 'solid-js' +import type { ColumnDrag } from './create-column-drag' +import type { + TradingHeader, + TradingTableInstance, +} from './trading-table-features' + +export interface TradingTableHeaderProps { + table: TradingTableInstance +} + +export function TradingTableHeader(props: TradingTableHeaderProps) { + const columnDrag = createColumnDrag(props.table) + + return ( + + + {(headerGroup) => ( + + + {(header) => ( + + )} + + + )} + + + ) +} + +interface TradingHeaderCellProps { + header: TradingHeader + columnDrag: ColumnDrag +} + +function TradingHeaderCell(props: TradingHeaderCellProps) { + const isLeaf = () => props.header.subHeaders.length === 0 + const headerCellProps = createHeaderCellProps( + () => props.header, + isLeaf, + props.columnDrag, + ) + + return ( + + + }> + + + + + ) +} + +function TradingLeafHeader(props: TradingHeaderCellProps) { + const dropZoneProps = props.columnDrag.createDropZoneProps( + props.header.column.id, + ) + const dragHandleProps = props.columnDrag.createHandleProps( + props.header.column.id, + ) + const sortButtonProps = createSortButtonProps(() => props.header) + const resizeHandleProps = createResizeHandleProps(() => props.header) + + return ( + <> +
+ + +
+ +
+ + + ) +} + +function createHeaderCellProps( + header: () => TradingHeader, + isLeaf: () => boolean, + columnDrag: ColumnDrag, +): JSX.IntrinsicElements['th'] { + return { + get colSpan() { + return header().colSpan + }, + get style() { + return { + width: `calc(var(--header-${header().id}-size) * 1px)`, + } + }, + get ['aria-sort']() { + return isLeaf() ? sortAriaValue(header().column.getIsSorted()) : undefined + }, + get classList() { + const columnId = header().column.id + return { + 'column-group-header': !isLeaf(), + 'numeric-header': isLeaf() && !isTextColumn(columnId), + 'is-column-dragging': columnDrag.isDragging(columnId), + 'is-column-drop-target': columnDrag.isDropTarget(columnId), + } + }, + } +} + +function createSortButtonProps( + header: () => TradingHeader, +): JSX.IntrinsicElements['button'] { + return { + type: 'button', + class: 'sort-header-button', + get classList() { + return { 'is-sortable': header().column.getCanSort() } + }, + get disabled() { + return !header().column.getCanSort() + }, + onClick: header().column.getToggleSortingHandler(), + } +} + +function createResizeHandleProps( + header: () => TradingHeader, +): JSX.IntrinsicElements['div'] { + return { + class: 'column-resize-handle', + get classList() { + return { 'is-resizing': header().column.getIsResizing() } + }, + role: 'separator', + 'aria-orientation': 'vertical', + onDblClick: () => header().column.resetSize(), + onMouseDown: header().getResizeHandler(), + onTouchStart: header().getResizeHandler(), + } +} + +function isTextColumn(columnId: string): boolean { + return columnId === 'market' || columnId === 'name' || columnId === 'symbol' +} diff --git a/examples/solid/realtime-trading/src/table/trading-table-row.tsx b/examples/solid/realtime-trading/src/table/trading-table-row.tsx new file mode 100644 index 0000000000..6d25d8258a --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table-row.tsx @@ -0,0 +1,105 @@ +import { FlexRender } from '@tanstack/solid-table' +import { Index, createMemo } from 'solid-js' +import { TradingRowDataProvider } from './trading-row-data-context' +import type { VirtualItem } from '@tanstack/solid-virtual' +import type { TradingColumnSet } from './table-config/trading-columns' +import type { TradingCell, TradingRow } from './trading-table-features' + +export interface TradingTableRowProps { + row: TradingRow + columnVersion: TradingColumnSet + selectedSymbol: string | null + virtualItem?: VirtualItem +} + +interface TradingTableCellProps { + cell: TradingCell + renderCell: TradingCell +} + +export function TradingTableRow(props: TradingTableRowProps) { + const currentCells = createMemo(() => props.row.getVisibleCells()) + const renderState = createMemo( + () => ({ + rowId: props.row.id, + columns: props.columnVersion, + cells: currentCells(), + }), + undefined, + { + equals: (previous, next) => + previous.rowId === next.rowId && + previous.columns === next.columns && + haveSameCellOrder(previous.cells, next.cells), + }, + ) + + return ( + props.row.original}> + + + {(cell, index) => ( + + )} + + + + ) +} + +function TradingTableCell(props: TradingTableCellProps) { + const selection = createMemo(() => ({ + edges: props.cell.getSelectionEdges(), + focused: props.cell.getIsFocused(), + selected: props.cell.getIsSelected(), + tabIndex: props.cell.getTabIndex(), + })) + + return ( + + + + ) +} + +function haveSameCellOrder( + previous: ReadonlyArray, + next: ReadonlyArray, +): boolean { + return ( + previous.length === next.length && + previous.every((cell, index) => cell.column.id === next[index]?.column.id) + ) +} diff --git a/examples/solid/realtime-trading/src/table/trading-table.tsx b/examples/solid/realtime-trading/src/table/trading-table.tsx new file mode 100644 index 0000000000..6c19ecfbeb --- /dev/null +++ b/examples/solid/realtime-trading/src/table/trading-table.tsx @@ -0,0 +1,77 @@ +import { createSignal } from 'solid-js' +import { + useMarketFeedController, + useTradingShellController, +} from '../shell/trading-shell-context' +import { createTradingTableModel } from './create-trading-table-model' +import { + createTradingGridProps, + createTradingTableElementProps, +} from './trading-grid-props' +import { + createFeedCommitTracking, + createTableAutoFit, +} from './trading-table-effects' +import { createTradingRowVirtualization } from './trading-row-virtualizer' +import { TradingTableBody } from './trading-table-body' +import { TradingTableFooter } from './trading-table-footer' +import { TradingTableHeader } from './trading-table-header' + +export function TradingTable() { + const feed = useMarketFeedController() + const { state, actions } = useTradingShellController() + const [scrollElement, setScrollElement] = createSignal( + null, + ) + const virtualized = () => state.virtualScrollMode() === 'tanstack' + const model = createTradingTableModel({ + quotes: feed.state.quotes, + rendererMode: state.rendererMode, + onSelectSymbol: actions.selectSymbol, + }) + const virtualization = createTradingRowVirtualization({ + rows: model.rows, + scrollElement, + enabled: virtualized, + onRenderedRowCount: actions.setRenderedRowCount, + }) + const elementOptions = { table: model.table, virtualized } + const gridProps = createTradingGridProps(elementOptions) + const tableProps = createTradingTableElementProps( + elementOptions, + model.tableStyle, + ) + + createFeedCommitTracking(feed.state.quotes, feed.completeRender) + createTableAutoFit(model.table, scrollElement) + + return ( + <> +
{ + setScrollElement(element) + }} + > + + + +
+
+ + + ) +} diff --git a/examples/solid/realtime-trading/src/vite-env.d.ts b/examples/solid/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/solid/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..732d62d455 --- /dev/null +++ b/examples/solid/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,211 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Solid realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + const livePrice = table.locator('tbody tr').first().locator('td').nth(3) + const initialPrice = await livePrice.textContent() + await expect.poll(() => livePrice.textContent()).not.toBe(initialPrice) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + const dragStart = table.locator('tbody tr').nth(0).locator('td').nth(1) + const dragEnd = table.locator('tbody tr').nth(2).locator('td').nth(4) + const dragStartBox = await dragStart.boundingBox() + const dragEndBox = await dragEnd.boundingBox() + expect(dragStartBox).not.toBeNull() + expect(dragEndBox).not.toBeNull() + if (dragStartBox && dragEndBox) { + await page.mouse.move( + dragStartBox.x + dragStartBox.width / 2, + dragStartBox.y + dragStartBox.height / 2, + ) + await page.mouse.down() + await page.mouse.move( + dragEndBox.x + dragEndBox.width / 2, + dragEndBox.y + dragEndBox.height / 2, + { steps: 12 }, + ) + await page.mouse.up() + } + await expect(table.locator('td[aria-selected="true"]')).toHaveCount(12) + await expect(table.locator('td[data-cell-focused="true"]')).toHaveCount(1) + await expect(table.locator('td[data-selection-top="true"]')).toHaveCount(4) + await expect(table.locator('td[data-selection-right="true"]')).toHaveCount( + 3, + ) + await expect(table.locator('td[data-selection-bottom="true"]')).toHaveCount( + 4, + ) + await expect(table.locator('td[data-selection-left="true"]')).toHaveCount(3) + await page.getByTestId('feed-toggle').click() + + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/solid/realtime-trading/tsconfig.json b/examples/solid/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..24795ad41a --- /dev/null +++ b/examples/solid/realtime-trading/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/solid/realtime-trading/vite.config.ts b/examples/solid/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..eb6fcca05e --- /dev/null +++ b/examples/solid/realtime-trading/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import solidPlugin from 'vite-plugin-solid' + +export default defineConfig({ + server: { + port: 7779, + allowedHosts: true, + }, + plugins: [solidPlugin()], + build: { + target: 'esnext', + }, +}) diff --git a/examples/svelte/realtime-trading/.gitignore b/examples/svelte/realtime-trading/.gitignore new file mode 100644 index 0000000000..a2ae9ce43f --- /dev/null +++ b/examples/svelte/realtime-trading/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.svelte-kit + diff --git a/examples/svelte/realtime-trading/README.md b/examples/svelte/realtime-trading/README.md new file mode 100644 index 0000000000..78c75b34c2 --- /dev/null +++ b/examples/svelte/realtime-trading/README.md @@ -0,0 +1,152 @@ +# Svelte realtime trading benchmark + +This standalone example exercises the current TanStack Svelte Table adapter +with a high-frequency worker feed, immutable market snapshots, interactive +columns, custom Svelte cells, virtual rows, and browser diagnostics. It is a +repeatable rendering workload rather than an exchange/network simulator. + +## Run and verify + +```bash +pnpm --dir examples/svelte/realtime-trading dev +``` + +Open `http://localhost:7782`. + +```bash +pnpm --dir examples/svelte/realtime-trading test:types +pnpm --dir examples/svelte/realtime-trading lint +pnpm --dir examples/svelte/realtime-trading build +pnpm --dir examples/svelte/realtime-trading test:e2e +``` + +Use a production build for performance recordings. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `src/feed/` | Market model, instrument universe, feed config, immutable updates, and feed controller. | +| `src/feed/worker/` | Typed worker protocol, deterministic engine, and module worker. | +| `src/benchmark/` | Browser monitor, benchmark controller, table observer, and row-model timing. | +| `src/shell/` | Svelte context plus separate header, metrics, configurator, diagnostics, selected-instrument, and status components. | +| `src/table/table-config/` | Grouped columns and dedicated Price/Move/Percent/Sparkline Svelte components. | +| `src/table/` | Svelte Table setup, table view, delegated interactions, column layout, and Svelte Virtual integration. | +| `src/App.svelte` | Creates/provides controllers and starts/stops them with the app lifecycle. | + +The feed and benchmark are separate controllers. The feed owns worker state and +quotes; the benchmark registers observers and owns metrics/view controls. +Svelte context passes those controllers without routing the quote array through +the root component. `quotes` is a dedicated high-frequency TanStack atom, and +each feed status/configuration value is a separate atom. Shell pieces subscribe +only to the atoms they display; benchmark metrics remain a coherent snapshot +store. + +## Feed and worker pipeline + +The default is 100 instruments, 10K generated samples/s, 20 ms delivery, +enabled intraday charts, and 16 ms chart sampling. + +Worker-private mutable quotes are changed by a deterministic 16 ms budget loop. +A row-indexed `Map` coalesces repeated instrument changes. An independent timer +publishes the latest unique rows at the selected cadence. Applying a message +creates a new outer array and new objects only for changed rows; untouched rows +and unsampled histories retain their references. Session IDs prevent late +messages from an old reset/row-count session from entering current state. + +- **Synthetic quote workload** is worker-side generated samples/s, not messages + or Svelte updates. +- **Worker delivery interval** controls coalesced messages; 20 ms targets around + 50 messages/s. +- **Row updates** counts unique immutable row objects applied. +- **Message samples** is the generated work represented by the latest message. + +Intraday history sampling is independent. The 25K burst deliberately publishes +one heavy batch. This resembles an upstream stream but excludes network delay. + +## Svelte table architecture + +The 14 leaf columns are grouped into Instrument, Price & Change, Order Book, +Session, and Chart. Sorting/filtering, on-change resizing, double-click reset, +drag column ordering, row selection, drag cell ranges, keyboard navigation, +CSS-only hover, and component-based quote cells are included. + +`TradingTable.svelte` owns the view, while `trading-table.ts` and +`table-config/` isolate table construction and columns. Stable instrument IDs +back row identity. The custom quote components make component executions and +lifecycle churn measurable; the A/B move mode intentionally changes component +type and is not the normal rendering baseline. + +Selection uses one delegated body-level interaction controller and resolves +cells from `composedPath()` plus data attributes. Column dimensions are CSS +custom properties updated only for sizing/order. A `ResizeObserver` performs +initial fitting and yields after the user resizes. + +## Virtualization + +- Below 200 rows, automatic mode chooses Full DOM, but Virtual can be enabled. +- From 200 through 1,499 rows, automatic mode chooses TanStack Virtual and Full + DOM remains selectable. +- At 1,500 rows or more, Virtual is forced and the control is disabled. + +Svelte Virtual uses a 32 px estimate, 10-row overscan, row IDs for item keys, +transformed rows, and a spacer body. The virtualizer range feeds the current-row +footer. Both modes use `content-visibility: auto`; Full DOM still creates every +Svelte row/cell even if the browser skips offscreen paint/layout work. + +## Performance decisions + +- worker generation and pre-message coalescing; +- immutable structural sharing for unchanged rows/history; +- controller context instead of root-level data prop drilling; +- stable keyed row identity; +- table/config/cell component boundaries with localized atom/store reads; +- delegated pointer selection and CSS hover; +- CSS variables for column width propagation; +- configurable chart frequency and opt-in component churn; +- virtual mounting for larger row sets; +- low-frequency metric publication relative to feed updates. + +Publishing immutable state necessarily changes the outer array. Stable inner +references reduce downstream work, but sorting/filtering can still require a +new table row-model pass. + +## Diagnostics and interpretation + +The sidebar starts with four cross-framework health signals: estimated rAF +callbacks/s over one second, average snapshot-to-DOM-commit latency over three +seconds, long animation frames accumulated since reset, and throughput as +changed rows/s plus applied snapshots/s. “Changed rows” is deduplicated within +each snapshot; the same instrument can count again in a later snapshot. The +advanced diagnostics retain worker samples/messages, DOM commits, a rolling +10-second p95/max commit latency, slow commits, lifecycle/execution rates, +row-model timing, DOM mutation records, and optional heap information. + +The frame figure is deliberately labeled estimated: it counts this page's rAF +callbacks, is capped by the display refresh rate, and falls when a tab is +throttled. It is a portable responsiveness signal, not compositor-presented +FPS. + +Svelte waits for `tick()` before closing a pending DOM-commit measurement. User +Timing entries for commits and row-model work are sampled at one in 20 calls; +the in-memory counters and latency windows still measure every call. + +Renderer callbacks and DOM mutations measure different layers. Heap growth is +not automatically a leak; verify retained objects after GC. The heap value is +Chromium-only and GC-sensitive, not retained size. The DOM rate counts +`MutationRecord` objects rather than browser operations, and records may be +coalesced; the observer watches text/child changes plus only `class`/`style` +attributes to reduce its own overhead. Non-feed text/child changes and +interaction-driven `class`/`style` changes (including virtual scrolling) are +included, so this is not a feed-only rate. Use identical production settings, +Chrome Performance, and Svelte DevTools when comparing runs. + +## Standalone policy + +This directory deliberately contains copies of the feed, worker, instruments, +benchmark, shell, styles, and table code. It can run independently or be copied +to StackBlitz, so shared code and README sections are repeated across adapters +by design. + +The workspace resolves the pinned `@tanstack/svelte-table` dependency to the +local adapter package while preserving a release-like manifest. diff --git a/examples/svelte/realtime-trading/index.html b/examples/svelte/realtime-trading/index.html new file mode 100644 index 0000000000..fa207c8adc --- /dev/null +++ b/examples/svelte/realtime-trading/index.html @@ -0,0 +1,2 @@ +
+ diff --git a/examples/svelte/realtime-trading/package.json b/examples/svelte/realtime-trading/package.json new file mode 100644 index 0000000000..92fd0558a1 --- /dev/null +++ b/examples/svelte/realtime-trading/package.json @@ -0,0 +1,28 @@ +{ + "name": "tanstack-svelte-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "svelte-check --tsconfig ./tsconfig.json", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/store": "^0.11.0", + "@tanstack/svelte-store": "^0.12.0", + "@tanstack/svelte-table": "9.1.2", + "@tanstack/svelte-virtual": "^3.13.35", + "svelte": "^5.56.8" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@tsconfig/svelte": "^5.0.8", + "svelte-check": "^4.7.4", + "typescript": "6.0.3", + "vite": "^8.2.0" + } +} diff --git a/examples/svelte/realtime-trading/src/App.svelte b/examples/svelte/realtime-trading/src/App.svelte new file mode 100644 index 0000000000..2464cea6b1 --- /dev/null +++ b/examples/svelte/realtime-trading/src/App.svelte @@ -0,0 +1,20 @@ + + + diff --git a/examples/svelte/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/svelte/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..8c3fb49821 --- /dev/null +++ b/examples/svelte/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,434 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCalls: 0 } + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCalls++ + if (userTiming.measureCalls % 20 !== 0) return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + pendingMutationStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markCommitPending(): void { + this.#runtime.pendingMutationStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingMutationStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingMutationStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingMutationStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingMutationStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingMutationStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/svelte/realtime-trading/src/benchmark/table-benchmark.ts b/examples/svelte/realtime-trading/src/benchmark/table-benchmark.ts new file mode 100644 index 0000000000..346a6cdc0c --- /dev/null +++ b/examples/svelte/realtime-trading/src/benchmark/table-benchmark.ts @@ -0,0 +1,23 @@ +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function startTableBenchmark( + controller: TradingBenchmarkController, +): () => void { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) return () => undefined + + controller.monitor.resetDomMutations() + const observer = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + observer.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + return () => observer.disconnect() +} diff --git a/examples/svelte/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/svelte/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..12e65e30ea --- /dev/null +++ b/examples/svelte/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,153 @@ +import { batch, createAtom, createStore } from '@tanstack/store' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkState { + requestedVirtualScrollMode: VirtualScrollPreference + metrics: FeedMetrics + mountedCells: number + liveComponents: number + longAnimationFramesSupported: boolean +} + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +const initialState: TradingBenchmarkState = { + requestedVirtualScrollMode: 'auto', + metrics: initialMetrics, + mountedCells: 0, + liveComponents: 0, + longAnimationFramesSupported, +} + +export class TradingBenchmarkController { + readonly store = createStore(initialState) + readonly renderAtoms = { + selectedSymbol: createAtom(null), + rendererMode: createAtom('stable'), + } + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.renderAtoms.selectedSymbol.set(null) + }, + setRendererMode: (mode) => { + this.renderAtoms.rendererMode.set(mode) + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.get() >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.#patch({ + requestedVirtualScrollMode: enabled ? 'tanstack' : 'none', + }) + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.store.get().mountedCells) { + this.#patch({ mountedCells }) + } + }, + selectSymbol: (symbol) => { + this.renderAtoms.selectedSymbol.set(symbol) + }, + resetMarket: () => { + batch(() => { + this.monitor.reset() + this.store.setState((state) => ({ + ...state, + metrics: { ...initialMetrics }, + mountedCells: 0, + liveComponents: 0, + })) + this.renderAtoms.selectedSymbol.set(null) + this.feed.actions.reset() + }) + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #patch(patch: Partial): void { + this.store.setState((state) => ({ ...state, ...patch })) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.#patch({ + metrics, + liveComponents: metrics.componentsCreated - metrics.componentsDestroyed, + }) + } +} diff --git a/examples/svelte/realtime-trading/src/feed/feed-sample-rates.ts b/examples/svelte/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/svelte/realtime-trading/src/feed/market-data.ts b/examples/svelte/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/svelte/realtime-trading/src/feed/market-feed-config.ts b/examples/svelte/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/svelte/realtime-trading/src/feed/market-feed-controller.ts b/examples/svelte/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..40f523562d --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,225 @@ +import { batch, createAtom } from '@tanstack/store' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = createAtom(false) + readonly running = createAtom(true) + readonly instrumentCount = createAtom(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = createAtom( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = createAtom( + initialMarketFeedConfig.publishIntervalMs, + ) + readonly updateSparklines = createAtom( + initialMarketFeedConfig.updateSparklines, + ) + readonly sparklineSampleIntervalMs = createAtom( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = createAtom>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.get() + this.running.set(running) + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.set(count) + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.set(sampleRate) + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.set(publishIntervalMs) + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.set(enabled) + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.set(intervalMs) + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.get()), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.get(), + running: this.running.get(), + ticksPerSecond: this.targetTicksPerSecond.get(), + publishIntervalMs: this.publishIntervalMs.get(), + updateSparklines: this.updateSparklines.get(), + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.get(), + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.get() }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + batch(() => { + this.quotes.set(quotes) + this.workerReady.set(true) + }) + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.set(applyMarketUpdates(this.quotes.get(), data.updates)) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + batch(() => { + this.workerReady.set(false) + this.running.set(false) + }) + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.set(false) + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/svelte/realtime-trading/src/feed/market-instruments.ts b/examples/svelte/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/svelte/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/svelte/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/svelte/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/svelte/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/svelte/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/svelte/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/svelte/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/svelte/realtime-trading/src/index.css b/examples/svelte/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/svelte/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/svelte/realtime-trading/src/main.ts b/examples/svelte/realtime-trading/src/main.ts new file mode 100644 index 0000000000..acece3d643 --- /dev/null +++ b/examples/svelte/realtime-trading/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './index.css' + +mount(App, { target: document.getElementById('app')! }) diff --git a/examples/svelte/realtime-trading/src/shell/AppHeader.svelte b/examples/svelte/realtime-trading/src/shell/AppHeader.svelte new file mode 100644 index 0000000000..1c8553316a --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/AppHeader.svelte @@ -0,0 +1,18 @@ + + +
+
MARKET MONITOR
+
+ {!workerReady.current ? 'FEED CONNECTING' : running.current ? 'FEED LIVE' : 'FEED PAUSED'} + +
+
diff --git a/examples/svelte/realtime-trading/src/shell/Configurator.svelte b/examples/svelte/realtime-trading/src/shell/Configurator.svelte new file mode 100644 index 0000000000..806cf4fa06 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/Configurator.svelte @@ -0,0 +1,48 @@ + + + diff --git a/examples/svelte/realtime-trading/src/shell/Diagnostics.svelte b/examples/svelte/realtime-trading/src/shell/Diagnostics.svelte new file mode 100644 index 0000000000..8faf513bd6 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/Diagnostics.svelte @@ -0,0 +1,43 @@ + + +

DIAGNOSTICS

+ {#each items as item (item[0])}
{item[0]}
{item[1]}
{/each} +
diff --git a/examples/svelte/realtime-trading/src/shell/MarketStatusbar.svelte b/examples/svelte/realtime-trading/src/shell/MarketStatusbar.svelte new file mode 100644 index 0000000000..21032c6d14 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/MarketStatusbar.svelte @@ -0,0 +1,13 @@ + + +
+ MESSAGE SAMPLES {integer.format(state.current.metrics.lastBatchSize)} + CHANGED ROWS {integer.format(state.current.metrics.lastUpdateCount)} + HOSTS {integer.format(state.current.mountedCells)} + COMPONENTS {integer.format(state.current.liveComponents)} +
diff --git a/examples/svelte/realtime-trading/src/shell/MetricsStrip.svelte b/examples/svelte/realtime-trading/src/shell/MetricsStrip.svelte new file mode 100644 index 0000000000..3ab0f30117 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/MetricsStrip.svelte @@ -0,0 +1,19 @@ + + +
+

LIVE HEALTH

+ {#each items as item (item.label)}
{item.label}{item.value}{item.detail}
{/each} +
THROUGHPUT{rate.format(metrics.rowUpdatesPerSecond)} rows/s{metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows deduplicated per snapshot
+
diff --git a/examples/svelte/realtime-trading/src/shell/SelectedInstrument.svelte b/examples/svelte/realtime-trading/src/shell/SelectedInstrument.svelte new file mode 100644 index 0000000000..dbf747480e --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/SelectedInstrument.svelte @@ -0,0 +1,16 @@ + + +
+

SELECTED INSTRUMENT

+ {#if selectedQuote} +
{selectedQuote.symbol}{selectedQuote.company}
{selectedQuote.venue}
+
Last
{selectedQuote.price.toFixed(2)}
Bid / ask
{selectedQuote.bid.toFixed(2)} / {selectedQuote.ask.toFixed(2)}
+ {:else}

Click or begin a cell selection in any row to inspect its instrument.

{/if} +
diff --git a/examples/svelte/realtime-trading/src/shell/TradingShell.svelte b/examples/svelte/realtime-trading/src/shell/TradingShell.svelte new file mode 100644 index 0000000000..6406fa8b71 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/TradingShell.svelte @@ -0,0 +1,15 @@ + + +
+
{ layout.sidebarOpen = !layout.sidebarOpen }} />{#if import.meta.env.DEV}{/if}
+
{@render children()}
+ + +
diff --git a/examples/svelte/realtime-trading/src/shell/configurator-options.ts b/examples/svelte/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/svelte/realtime-trading/src/shell/trading-shell-context.ts b/examples/svelte/realtime-trading/src/shell/trading-shell-context.ts new file mode 100644 index 0000000000..25ab35c22c --- /dev/null +++ b/examples/svelte/realtime-trading/src/shell/trading-shell-context.ts @@ -0,0 +1,35 @@ +import { getContext, setContext } from 'svelte' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +interface TradingControllers { + benchmark: TradingBenchmarkController + feed: MarketFeedController +} + +const tradingControllersKey = Symbol('trading-controllers') + +export function provideTradingControllers( + benchmark: TradingBenchmarkController, +): void { + setContext(tradingControllersKey, { + benchmark, + feed: benchmark.feed, + }) +} + +export function useTradingShellController(): TradingBenchmarkController { + const controllers = getContext( + tradingControllersKey, + ) + if (!controllers) throw new Error('Missing trading controllers') + return controllers.benchmark +} + +export function useMarketFeedController(): MarketFeedController { + const controllers = getContext( + tradingControllersKey, + ) + if (!controllers) throw new Error('Missing trading controllers') + return controllers.feed +} diff --git a/examples/svelte/realtime-trading/src/table/TradingTable.svelte b/examples/svelte/realtime-trading/src/table/TradingTable.svelte new file mode 100644 index 0000000000..53fed2fa16 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/TradingTable.svelte @@ -0,0 +1,166 @@ + + +{#snippet renderRow(row: (typeof rows)[number], virtualRow?: VirtualItem)} + + {#each row.getVisibleCells() as cell (cell.id)}{@const edges = cell.getSelectionEdges()}{/each} + +{/snippet} + +
+ handleCellNavigation(table, event)}> + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + {#each headerGroup.headers as header (header.id)} + {@const isLeaf = header.subHeaders.length === 0}{@const sorted = header.column.getIsSorted()} + + {/each} + {/each} + + + + + pointerInteractions.handleMouseDown(table, event, controller.actions.selectSymbol)} onpointerover={(event) => pointerInteractions.handlePointerOver(table, event)} onmouseleave={() => pointerInteractions.resetPointerCell()} onclick={(event) => pointerInteractions.handleClick(table, event)}> + {#if virtualScrollMode === 'tanstack'} + {#each virtualRows as virtualRow (virtualRow.key)}{@render renderRow(rows[virtualRow.index], virtualRow)}{/each} + {:else} + {#each rows as row (row.id)}{@render renderRow(row)}{/each} + {/if} + +
+ {#if !header.isPlaceholder} + {#if isLeaf} +
{ event.preventDefault(); showColumnDropTarget(header.column.id, (event.currentTarget as HTMLElement).closest('th')) }} ondrop={(event) => { event.preventDefault(); const sourceId = event.dataTransfer?.getData('text/plain') || drag.columnId; if (sourceId) table.setColumnOrder(reorderColumnIds(table.getVisibleLeafColumns().map((column) => column.id), sourceId, header.column.id)); clearColumnDrag() }}> + + +
+ {#if header.column.getCanResize()}{/if} + {:else}{/if} + {/if} +
+
+{#if virtualScrollMode === 'tanstack'}
TanStack · Total · {rows.length} rows · {table.getVisibleLeafColumns().length} columns{visibleRange ? `Current · rows ${visibleRange.start}..${visibleRange.end}` : 'Current · rows —'}
{/if} diff --git a/examples/svelte/realtime-trading/src/table/table-config/DayChangeCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/DayChangeCell.svelte new file mode 100644 index 0000000000..ac9fd4210c --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/DayChangeCell.svelte @@ -0,0 +1,19 @@ + + +{#if mode.current === 'stable'} + +{:else if change >= 0} + +{:else} + +{/if} diff --git a/examples/svelte/realtime-trading/src/table/table-config/LastPriceCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/LastPriceCell.svelte new file mode 100644 index 0000000000..2fe41997c7 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/LastPriceCell.svelte @@ -0,0 +1,11 @@ + + + selectSymbol(quote.symbol)} /> diff --git a/examples/svelte/realtime-trading/src/table/table-config/MoveCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/MoveCell.svelte new file mode 100644 index 0000000000..0ecf1544b2 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/MoveCell.svelte @@ -0,0 +1,17 @@ + + +{indicator}{formatSigned(move)} diff --git a/examples/svelte/realtime-trading/src/table/table-config/PercentChangeCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/PercentChangeCell.svelte new file mode 100644 index 0000000000..d108a65007 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/PercentChangeCell.svelte @@ -0,0 +1,10 @@ + + += 0} class:quote-down={value < 0} class="percent-change-cell">{value >= 0 ? '+' : ''}{value.toFixed(2)}% diff --git a/examples/svelte/realtime-trading/src/table/table-config/PriceCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/PriceCell.svelte new file mode 100644 index 0000000000..4f25bd5fd5 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/PriceCell.svelte @@ -0,0 +1,10 @@ + + + diff --git a/examples/svelte/realtime-trading/src/table/table-config/SparklineCell.svelte b/examples/svelte/realtime-trading/src/table/table-config/SparklineCell.svelte new file mode 100644 index 0000000000..bee2789d9d --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/SparklineCell.svelte @@ -0,0 +1,12 @@ + + + diff --git a/examples/svelte/realtime-trading/src/table/table-config/quote-cells.ts b/examples/svelte/realtime-trading/src/table/table-config/quote-cells.ts new file mode 100644 index 0000000000..ddc96e7296 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/quote-cells.ts @@ -0,0 +1,75 @@ +export const quoteCellLifecycle = { created: 0, destroyed: 0 } + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = (names: ReadonlyArray) => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +export function recordComponentRender(name: QuoteComponentName): void { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[name]++ +} + +export function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} + +export function sparklinePoints(values: ReadonlyArray): string { + const first = values[0] ?? 0 + const range = values.reduce( + (current, value) => ({ + min: Math.min(current.min, value), + max: Math.max(current.max, value), + }), + { min: first, max: first }, + ) + const height = range.max - range.min || 1 + const denominator = Math.max(1, values.length - 1) + return values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - range.min) / height) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') +} diff --git a/examples/svelte/realtime-trading/src/table/table-config/trading-columns.ts b/examples/svelte/realtime-trading/src/table/table-config/trading-columns.ts new file mode 100644 index 0000000000..9163e9022f --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-config/trading-columns.ts @@ -0,0 +1,243 @@ +import { renderComponent } from '@tanstack/svelte-table' +import DayChangeCell from './DayChangeCell.svelte' +import LastPriceCell from './LastPriceCell.svelte' +import PercentChangeCell from './PercentChangeCell.svelte' +import SparklineCell from './SparklineCell.svelte' +import { recordCellRender } from './quote-cells' +import type { MarketQuote } from '../../feed/market-data' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +export interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => unknown +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const tradingColumns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender( + 'Last', + renderComponent(LastPriceCell, { quote: row.original }), + ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: getDayChange, + cell: ({ row }) => + recordCellRender( + 'Change', + renderComponent(DayChangeCell, { quote: row.original }), + ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: getDayChangePercent, + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + renderComponent(PercentChangeCell, { + value: getDayChangePercent(row.original), + }), + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender( + 'BidVolume', + compactFormatter.format(row.original.bidSize), + ), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender( + 'AskVolume', + compactFormatter.format(row.original.askSize), + ), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender( + 'Intraday', + renderComponent(SparklineCell, { values: row.original.history }), + ), + }, + ], + }, +] + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 20_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + } catch { + /* User Timing Level 3 is optional. */ + } + } + return rows +} + +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/svelte/realtime-trading/src/table/table-interactions.ts b/examples/svelte/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..99649a40bd --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,187 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/svelte/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/svelte/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/svelte/realtime-trading/src/table/trading-table.ts b/examples/svelte/realtime-trading/src/table/trading-table.ts new file mode 100644 index 0000000000..77e4e6bee1 --- /dev/null +++ b/examples/svelte/realtime-trading/src/table/trading-table.ts @@ -0,0 +1,9 @@ +export { + TRADING_COLUMN_COUNT, + rowModelDiagnostics, +} from './table-config/trading-columns' +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns' +export type { VirtualScrollMode } from './trading-row-virtualizer' diff --git a/examples/svelte/realtime-trading/src/vite-env.d.ts b/examples/svelte/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/svelte/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts b/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..6cad646e8d --- /dev/null +++ b/examples/svelte/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Svelte realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/svelte/realtime-trading/tsconfig.json b/examples/svelte/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..f5abb31723 --- /dev/null +++ b/examples/svelte/realtime-trading/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["svelte", "vite/client"] + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.svelte", + "tests/**/*.ts", + "vite.config.ts" + ] +} diff --git a/examples/svelte/realtime-trading/vite.config.ts b/examples/svelte/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..f05490e534 --- /dev/null +++ b/examples/svelte/realtime-trading/vite.config.ts @@ -0,0 +1,7 @@ +import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { port: 7782, allowedHosts: true }, + plugins: [svelte()], +}) diff --git a/examples/vue/realtime-trading/README.md b/examples/vue/realtime-trading/README.md new file mode 100644 index 0000000000..2a1b4303df --- /dev/null +++ b/examples/vue/realtime-trading/README.md @@ -0,0 +1,156 @@ +# Vue realtime trading benchmark + +This standalone example exercises the current TanStack Vue Table adapter with +a high-frequency worker feed, immutable quote snapshots, interactive columns, +custom Vue cells, optional virtualization, and browser diagnostics. It is a +repeatable UI stress workload, not an exchange or network benchmark. + +## Run and verify + +```bash +pnpm --dir examples/vue/realtime-trading dev +``` + +Open `http://localhost:7781`. + +```bash +pnpm --dir examples/vue/realtime-trading test:types +pnpm --dir examples/vue/realtime-trading lint +pnpm --dir examples/vue/realtime-trading build +pnpm --dir examples/vue/realtime-trading test:e2e +``` + +Use the production build for recordings; Vue development checks add overhead. + +## Structure and ownership + +| Path | Responsibility | +| ------------------------- | ---------------------------------------------------------------------------------------------- | +| `src/feed/` | Market model, instrument universe, feed configuration, immutable updates, and feed controller. | +| `src/feed/worker/` | Typed protocol, deterministic market engine, and module worker. | +| `src/benchmark/` | Browser monitor, benchmark controller, table timing, lifecycle, and mutation observers. | +| `src/shell/` | Controller injection, viewport shell, controls, metrics, selected instrument, and diagnostics. | +| `src/table/table-config/` | Grouped columns and custom Vue quote cells. | +| `src/table/` | Vue Table instance, delegated interactions, column layout, and Vue Virtual integration. | +| `src/App.tsx` | Creates/starts controllers, provides them, and composes shell plus table. | + +`MarketFeedController` owns worker/feed state; `TradingBenchmarkController` +observes it and owns benchmark/view state. Vue `provide`/`inject` distributes +stable controller objects. The controllers use Vue primitives directly: +`quotes` and metrics are independent `shallowRef` values, while feed status, +configuration, selection, and view state use focused `ref` values. Consumers +read only the refs they need, and the root never consumes the quote stream. +Cleanup stops both controllers on application unmount. + +## Feed and worker pipeline + +The default run uses 100 instruments, 10K generated samples/s, 20 ms delivery, +enabled intraday charts, and 16 ms chart sampling. + +The worker keeps mutable market state private. Its deterministic generator runs +from a 16 ms accrued budget, while a row-indexed `Map` coalesces repeated +updates. A separate publication timer sends the latest unique rows. The main +thread publishes a new quote array but replaces only changed row objects; +untouched rows and unsampled history arrays keep their references. Session IDs +discard messages from an obsolete reset/instrument configuration. + +- **Synthetic quote workload** is generated samples/s in the worker, not Vue + renders or `postMessage` calls. +- **Worker delivery interval** is the target coalesced-message cadence; 20 ms + targets roughly 50 messages/s. +- **Row updates** counts unique immutable rows applied by a message. +- **Message samples** is the generated work represented by the latest batch. + +Chart sampling is independent of price sampling. The 25K burst immediately +creates and publishes one heavy batch. Worker messaging approximates an +upstream stream, but actual network latency is outside the benchmark. + +## Vue table architecture + +The grid has 14 leaf columns grouped into Instrument, Price & Change, Order +Book, Session, and Chart. It supports sorting/filtering, on-change resizing, +double-click reset, drag ordering, CSS row hover, row selection, drag cell +ranges, keyboard navigation, and component-based Price/Move/Percent/Sparkline +cells. + +The table accepts the dedicated quotes `shallowRef` directly and uses the +instrument ID for `getRowId`. TanStack Vue Table's state atoms are Vue-backed, +so row-model computations and layout watchers read only the required atoms from +native `computed`/`watch` boundaries. Dedicated row views keep row markup and +selected-instrument state below the whole-table boundary. + +A single `TradingGridPointerController` handles body pointer events. It maps +`event.composedPath()` and data attributes back to the TanStack cell, avoiding +one handler per cell. Column sizes are CSS variables updated only when sizing +or order changes. `ResizeObserver` performs initial fit until manual resizing. +The A/B Move renderer is an explicit component lifecycle stress mode. + +## Virtualization + +- Below 200 rows, automatic mode uses Full DOM, but Virtual is selectable. +- From 200 through 1,499 rows, automatic mode uses TanStack Virtual, while Full + DOM remains selectable. +- At 1,500 rows or more, Virtual is forced and its control is locked. + +Vue Virtual uses 32 px row estimates, 10-row overscan, instrument IDs as item +keys, transformed rows, and a spacer body. The visible-range footer reads the +virtualizer range. Both paths apply `content-visibility: auto`; in Full DOM it +can reduce browser rendering but cannot prevent Vue from creating all rows. + +## Performance decisions + +- market generation/coalescing is moved off the main thread; +- immutable structural sharing preserves untouched rows/histories; +- focused Vue refs and computed values narrow reactive invalidation; +- stable row and virtual item keys preserve identity; +- row rendering is componentized without subscribing the app root to data; +- pointer selection is delegated once; +- CSS variables separate width changes from quote updates; +- component swapping and chart frequency are opt-in stress controls; +- virtualization limits framework and DOM mounts; +- metrics are published more slowly than the feed. + +The immutable outer array must change for a batch. Structural sharing can reduce +cell work, but sorted/filtered row models may still run when the data input +changes. + +## Diagnostics and interpretation + +The sidebar starts with four cross-framework health signals: estimated rAF +callbacks/s over one second, average snapshot-to-DOM-commit latency over three +seconds, long animation frames accumulated since reset, and throughput as +changed rows/s plus applied snapshots/s. “Changed rows” is deduplicated within +each snapshot; the same instrument can count again in a later snapshot. The +advanced diagnostics retain worker samples/messages, DOM commits, a rolling +10-second p95/max commit latency, slow commits, lifecycle/execution rates, +row-model timing, DOM mutation records, and optional heap data. + +The frame figure is deliberately labeled estimated: it counts this page's rAF +callbacks, is capped by the display refresh rate, and falls when a tab is +throttled. It is a portable responsiveness signal, not compositor-presented +FPS. + +Vue closes the pending measurement from the table's mounted/updated commit +hooks. User Timing entries for commits and row-model work are sampled at one in +20 calls; the in-memory counters and latency windows still measure every call. + +Callback counts are not DOM mutation counts. Temporary heap growth during the +swap workload is not a leak unless post-GC snapshots retain instances. The heap +value is Chromium-only and GC-sensitive, not retained size. The DOM rate counts +`MutationRecord` objects rather than browser operations, and records may be +coalesced; the observer watches text/child changes plus only `class`/`style` +attributes to limit its own overhead. Non-feed text/child changes and +interaction-driven `class`/`style` changes (including virtual scrolling) are +included, so this is not a feed-only rate. Compare identical production +configurations and use Chrome Performance plus Vue DevTools for call stacks and +component detail. + +## Standalone policy + +The instruments, feed, worker, benchmark, shell, styles, and table code are +intentionally copied into this folder. This keeps the example independently +runnable and StackBlitz-ready, so shared implementation and README explanations +are duplicated across adapters by design. + +The workspace resolves the pinned `@tanstack/vue-table` dependency to the local +adapter package while retaining a release-like manifest. diff --git a/examples/vue/realtime-trading/index.html b/examples/vue/realtime-trading/index.html new file mode 100644 index 0000000000..3a941cafa2 --- /dev/null +++ b/examples/vue/realtime-trading/index.html @@ -0,0 +1,17 @@ + + + + + + + + Vue Real-time Trading FlexRender Lab + + +
+ + + diff --git a/examples/vue/realtime-trading/package.json b/examples/vue/realtime-trading/package.json new file mode 100644 index 0000000000..e61953cb18 --- /dev/null +++ b/examples/vue/realtime-trading/package.json @@ -0,0 +1,27 @@ +{ + "name": "tanstack-vue-table-example-realtime-trading", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint ./src", + "test:types": "vue-tsc --noEmit", + "test:e2e": "PLAYWRIGHT_TEST_DIR=$PWD/tests/e2e playwright test --config ../../../playwright.config.ts" + }, + "dependencies": { + "@tanstack/vue-table": "9.1.2", + "@tanstack/vue-virtual": "^3.13.35", + "vue": "^3.5.40" + }, + "devDependencies": { + "@types/node": "^26.1.2", + "@vitejs/plugin-vue": "^6.0.8", + "@vitejs/plugin-vue-jsx": "^5.1.6", + "typescript": "6.0.3", + "vite": "^8.2.0", + "vue-tsc": "^3.3.5" + } +} diff --git a/examples/vue/realtime-trading/src/App.tsx b/examples/vue/realtime-trading/src/App.tsx new file mode 100644 index 0000000000..d8346b2c66 --- /dev/null +++ b/examples/vue/realtime-trading/src/App.tsx @@ -0,0 +1,26 @@ +import { defineComponent, onBeforeUnmount } from 'vue' +import { TradingBenchmarkController } from './benchmark/trading-benchmark-controller' +import { MarketFeedController } from './feed/market-feed-controller' +import { TradingShell } from './shell/TradingShell' +import { provideTradingControllers } from './shell/trading-shell-context' +import { TradingTable } from './table/trading-table' + +export const App = defineComponent({ + name: 'RealtimeTradingApp', + setup() { + const feed = new MarketFeedController() + const benchmark = new TradingBenchmarkController(feed) + provideTradingControllers(benchmark) + + const stopFeed = feed.start() + const stopBenchmark = benchmark.start() + onBeforeUnmount(() => { + stopBenchmark() + stopFeed() + }) + + return () => ( + {{ default: () => }} + ) + }, +}) diff --git a/examples/vue/realtime-trading/src/benchmark/benchmark-monitor.ts b/examples/vue/realtime-trading/src/benchmark/benchmark-monitor.ts new file mode 100644 index 0000000000..8c3fb49821 --- /dev/null +++ b/examples/vue/realtime-trading/src/benchmark/benchmark-monitor.ts @@ -0,0 +1,434 @@ +import { + quoteCellLifecycle, + quoteRenderDiagnostics, +} from '../table/table-config/quote-cells' +import { rowModelDiagnostics } from '../table/trading-table' + +export interface NamedInvocationRate { + name: string + callsPerSecond: number +} + +interface TimedLatencySample { + recordedAt: number + duration: number +} + +const averageLatencyWindowMs = 3_000 +const percentileLatencyWindowMs = 10_000 +const frameRateWindowMs = 1_000 + +export interface FeedMetrics { + actualTicksPerSecond: number + rowUpdatesPerSecond: number + workerMessagesPerSecond: number + stateApplicationsPerSecond: number + supersededUpdatesPerSecond: number + totalTicks: number + rafCallbacksPerSecond: number + tableCommitsPerSecond: number + lastBatchSize: number + averageCommitLatencyMs: number + p95CommitLatencyMs: number + maxCommitLatencyMs: number + slowCommits: number + longAnimationFrames: number + worstLongAnimationFrameMs: number + heapMb: number | null + componentsCreated: number + componentsDestroyed: number + workerMessages: number + lastUpdateCount: number + cellRendererCallsPerSecond: number + componentRenderCallsPerSecond: number + cellRendererRates: ReadonlyArray + componentRenderRates: ReadonlyArray + domMutationsPerSecond: number + rowModelCallsPerSecond: number + rowModelAverageMs: number + rowModelMaxMs: number + visibleRows: number +} + +export const initialMetrics: FeedMetrics = { + actualTicksPerSecond: 0, + rowUpdatesPerSecond: 0, + workerMessagesPerSecond: 0, + stateApplicationsPerSecond: 0, + supersededUpdatesPerSecond: 0, + totalTicks: 0, + rafCallbacksPerSecond: 0, + tableCommitsPerSecond: 0, + lastBatchSize: 0, + averageCommitLatencyMs: 0, + p95CommitLatencyMs: 0, + maxCommitLatencyMs: 0, + slowCommits: 0, + longAnimationFrames: 0, + worstLongAnimationFrameMs: 0, + heapMb: null, + componentsCreated: 0, + componentsDestroyed: 0, + workerMessages: 0, + lastUpdateCount: 0, + cellRendererCallsPerSecond: 0, + componentRenderCallsPerSecond: 0, + cellRendererRates: [], + componentRenderRates: [], + domMutationsPerSecond: 0, + rowModelCallsPerSecond: 0, + rowModelAverageMs: 0, + rowModelMaxMs: 0, + visibleRows: 0, +} + +const userTiming = { entryCount: 0, measureCalls: 0 } + +export function recordMeasure( + name: string, + start: number, + end: number, + detail: Record, +): void { + userTiming.measureCalls++ + if (userTiming.measureCalls % 20 !== 0) return + try { + performance.measure(name, { start, end, detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMeasures('market-update-to-dom-commit') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } +} + +export function markBenchmarkAction( + name: string, + detail: Record = {}, +): void { + try { + performance.mark(`benchmark:${name}`, { detail }) + userTiming.entryCount++ + if (userTiming.entryCount % 1_000 === 0) { + performance.clearMarks() + } + } catch { + // User Timing Level 3 detail is not implemented in every browser. + } +} + +export class BenchmarkMonitor { + readonly #runtime = { + sampleStartedAt: performance.now(), + sessionStartedAt: performance.now(), + pendingMutationStartedAt: null as number | null, + commitLatencySamples: [] as Array, + slowCommitCount: 0, + totalTicks: 0, + ticksInSample: 0, + rowUpdatesInSample: 0, + workerMessagesInSample: 0, + stateApplicationsInSample: 0, + supersededUpdatesInSample: 0, + lastBatchSize: 0, + lastUpdateCount: 0, + workerMessages: 0, + frameTrackingStartedAt: performance.now(), + frameTimestamps: [] as Array, + tableCommitsInSample: 0, + longAnimationFrameCount: 0, + worstLongAnimationFrameMs: 0, + previousCellRendererCalls: 0, + previousComponentRenderCalls: 0, + previousCellRendererCallsByName: { + ...quoteRenderDiagnostics.cellRendererCallsByName, + }, + previousComponentRenderCallsByName: { + ...quoteRenderDiagnostics.componentRenderCallsByName, + }, + domMutationsInSample: 0, + previousRowModelCalls: 0, + previousRowModelDuration: 0, + } + + markCommitPending(): void { + this.#runtime.pendingMutationStartedAt ??= performance.now() + } + + recordDomCommit(): void { + const runtime = this.#runtime + if (runtime.pendingMutationStartedAt !== null) { + const commitEndedAt = performance.now() + const duration = commitEndedAt - runtime.pendingMutationStartedAt + runtime.commitLatencySamples.push({ + recordedAt: commitEndedAt, + duration, + }) + if (duration > 16.7) runtime.slowCommitCount++ + recordMeasure( + 'market-update-to-dom-commit', + runtime.pendingMutationStartedAt, + commitEndedAt, + {}, + ) + runtime.pendingMutationStartedAt = null + runtime.tableCommitsInSample++ + } + } + + recordWorkerMessage(): void { + this.#runtime.workerMessages++ + this.#runtime.workerMessagesInSample++ + } + + recordBatch( + tickCount: number, + updateCount: number, + supersededUpdateCount: number, + ): void { + const runtime = this.#runtime + runtime.lastBatchSize = tickCount + runtime.lastUpdateCount = updateCount + runtime.ticksInSample += tickCount + runtime.totalTicks += tickCount + runtime.rowUpdatesInSample += updateCount + runtime.stateApplicationsInSample++ + runtime.supersededUpdatesInSample += supersededUpdateCount + } + + recordAnimationFrame(now: number): void { + const timestamps = this.#runtime.frameTimestamps + timestamps.push(now) + pruneFrameTimestamps(timestamps, now) + } + + recordLongAnimationFrame(duration: number, startTime: number): void { + const runtime = this.#runtime + if (startTime < runtime.sessionStartedAt) return + runtime.longAnimationFrameCount++ + runtime.worstLongAnimationFrameMs = Math.max( + runtime.worstLongAnimationFrameMs, + duration, + ) + } + + recordDomMutations(count: number): void { + this.#runtime.domMutationsInSample += count + } + + resetDomMutations(): void { + this.#runtime.domMutationsInSample = 0 + } + + shouldPublish(now: number): boolean { + return now - this.#runtime.sampleStartedAt >= 500 + } + + publish(now: number): FeedMetrics { + const runtime = this.#runtime + const sampleDuration = now - runtime.sampleStartedAt + pruneLatencySamples(runtime.commitLatencySamples, now) + pruneFrameTimestamps(runtime.frameTimestamps, now) + const averageCommitLatencySamples = runtime.commitLatencySamples + .filter((sample) => sample.recordedAt >= now - averageLatencyWindowMs) + .map((sample) => sample.duration) + const percentileCommitLatencySamples = runtime.commitLatencySamples.map( + (sample) => sample.duration, + ) + const sortedCommitLatencySamples = [...percentileCommitLatencySamples].sort( + (left, right) => left - right, + ) + const rowModelCalls = + rowModelDiagnostics.calls - runtime.previousRowModelCalls + const rowModelDuration = + rowModelDiagnostics.totalDurationMs - runtime.previousRowModelDuration + const cellRendererCalls = + quoteRenderDiagnostics.cellRendererCalls - + runtime.previousCellRendererCalls + const componentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls - + runtime.previousComponentRenderCalls + const cellRendererRates = calculateInvocationRates( + quoteRenderDiagnostics.cellRendererCallsByName, + runtime.previousCellRendererCallsByName, + sampleDuration, + ) + const componentRenderRates = calculateInvocationRates( + quoteRenderDiagnostics.componentRenderCallsByName, + runtime.previousComponentRenderCallsByName, + sampleDuration, + ) + const averageCommitLatencyMs = + averageCommitLatencySamples.length === 0 + ? 0 + : averageCommitLatencySamples.reduce((sum, value) => sum + value, 0) / + averageCommitLatencySamples.length + const p95Index = Math.max( + 0, + Math.ceil(sortedCommitLatencySamples.length * 0.95) - 1, + ) + const metrics: FeedMetrics = { + actualTicksPerSecond: + sampleDuration === 0 + ? 0 + : (runtime.ticksInSample / sampleDuration) * 1_000, + rowUpdatesPerSecond: + (runtime.rowUpdatesInSample / sampleDuration) * 1_000, + workerMessagesPerSecond: + (runtime.workerMessagesInSample / sampleDuration) * 1_000, + stateApplicationsPerSecond: + (runtime.stateApplicationsInSample / sampleDuration) * 1_000, + supersededUpdatesPerSecond: + (runtime.supersededUpdatesInSample / sampleDuration) * 1_000, + totalTicks: runtime.totalTicks, + rafCallbacksPerSecond: calculateFrameRate( + runtime.frameTimestamps, + runtime.frameTrackingStartedAt, + now, + ), + tableCommitsPerSecond: + (runtime.tableCommitsInSample / sampleDuration) * 1_000, + lastBatchSize: runtime.lastBatchSize, + averageCommitLatencyMs, + p95CommitLatencyMs: sortedCommitLatencySamples[p95Index] ?? 0, + maxCommitLatencyMs: sortedCommitLatencySamples.at(-1) ?? 0, + slowCommits: runtime.slowCommitCount, + longAnimationFrames: runtime.longAnimationFrameCount, + worstLongAnimationFrameMs: runtime.worstLongAnimationFrameMs, + heapMb: readHeapSizeMb(), + componentsCreated: quoteCellLifecycle.created, + componentsDestroyed: quoteCellLifecycle.destroyed, + workerMessages: runtime.workerMessages, + lastUpdateCount: runtime.lastUpdateCount, + cellRendererCallsPerSecond: (cellRendererCalls / sampleDuration) * 1_000, + componentRenderCallsPerSecond: + (componentRenderCalls / sampleDuration) * 1_000, + cellRendererRates, + componentRenderRates, + domMutationsPerSecond: + (runtime.domMutationsInSample / sampleDuration) * 1_000, + rowModelCallsPerSecond: (rowModelCalls / sampleDuration) * 1_000, + rowModelAverageMs: + rowModelCalls === 0 ? 0 : rowModelDuration / rowModelCalls, + rowModelMaxMs: rowModelDiagnostics.maxDurationMs, + visibleRows: rowModelDiagnostics.lastRowCount, + } + + runtime.sampleStartedAt = now + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.tableCommitsInSample = 0 + return metrics + } + + reset(): void { + const runtime = this.#runtime + runtime.sampleStartedAt = performance.now() + runtime.sessionStartedAt = runtime.sampleStartedAt + runtime.pendingMutationStartedAt = null + runtime.commitLatencySamples = [] + runtime.slowCommitCount = 0 + runtime.totalTicks = 0 + runtime.ticksInSample = 0 + runtime.rowUpdatesInSample = 0 + runtime.workerMessagesInSample = 0 + runtime.stateApplicationsInSample = 0 + runtime.supersededUpdatesInSample = 0 + runtime.lastBatchSize = 0 + runtime.lastUpdateCount = 0 + runtime.workerMessages = 0 + runtime.frameTrackingStartedAt = runtime.sampleStartedAt + runtime.frameTimestamps = [] + runtime.tableCommitsInSample = 0 + runtime.longAnimationFrameCount = 0 + runtime.worstLongAnimationFrameMs = 0 + runtime.previousCellRendererCalls = quoteRenderDiagnostics.cellRendererCalls + runtime.previousComponentRenderCalls = + quoteRenderDiagnostics.componentRenderCalls + runtime.previousCellRendererCallsByName = { + ...quoteRenderDiagnostics.cellRendererCallsByName, + } + runtime.previousComponentRenderCallsByName = { + ...quoteRenderDiagnostics.componentRenderCallsByName, + } + runtime.domMutationsInSample = 0 + runtime.previousRowModelCalls = rowModelDiagnostics.calls + runtime.previousRowModelDuration = rowModelDiagnostics.totalDurationMs + rowModelDiagnostics.maxDurationMs = 0 + } +} + +export const longAnimationFramesSupported = + PerformanceObserver.supportedEntryTypes.includes('long-animation-frame') + +function readHeapSizeMb(): number | null { + const memory = ( + performance as Performance & { + memory?: { usedJSHeapSize: number } + } + ).memory + return memory ? memory.usedJSHeapSize / 1_048_576 : null +} + +function calculateInvocationRates( + current: Record, + previous: Record, + sampleDuration: number, +): ReadonlyArray { + return Object.entries(current).map(([name, calls]) => ({ + name, + callsPerSecond: + sampleDuration === 0 + ? 0 + : ((calls - (previous[name] ?? 0)) / sampleDuration) * 1_000, + })) +} + +function pruneLatencySamples( + samples: Array, + now: number, +): void { + const cutoff = now - percentileLatencyWindowMs + const firstRetainedIndex = samples.findIndex( + (sample) => sample.recordedAt >= cutoff, + ) + if (firstRetainedIndex > 0) samples.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) samples.length = 0 +} + +function pruneFrameTimestamps(timestamps: Array, now: number): void { + const cutoff = now - frameRateWindowMs + const firstRetainedIndex = timestamps.findIndex( + (timestamp) => timestamp >= cutoff, + ) + if (firstRetainedIndex > 0) timestamps.splice(0, firstRetainedIndex) + else if (firstRetainedIndex === -1) timestamps.length = 0 +} + +function calculateFrameRate( + timestamps: ReadonlyArray, + trackingStartedAt: number, + now: number, +): number { + const observedWindowMs = Math.min( + frameRateWindowMs, + Math.max(1, now - trackingStartedAt), + ) + return (timestamps.length / observedWindowMs) * 1_000 +} diff --git a/examples/vue/realtime-trading/src/benchmark/trading-benchmark-controller.ts b/examples/vue/realtime-trading/src/benchmark/trading-benchmark-controller.ts new file mode 100644 index 0000000000..21b68447ef --- /dev/null +++ b/examples/vue/realtime-trading/src/benchmark/trading-benchmark-controller.ts @@ -0,0 +1,127 @@ +import { ref, shallowRef } from 'vue' +import { TRADING_COLUMN_COUNT } from '../table/trading-table' +import { FORCED_VIRTUALIZATION_ROW_COUNT } from '../table/trading-row-virtualizer' +import { + BenchmarkMonitor, + initialMetrics, + longAnimationFramesSupported, +} from './benchmark-monitor' +import type { FeedMetrics } from './benchmark-monitor' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { RendererMode } from '../table/trading-table' +import type { VirtualScrollPreference } from '../table/trading-row-virtualizer' + +export interface TradingBenchmarkActions { + resetViewState: () => void + setRendererMode: (mode: RendererMode) => void + setVirtualScrollEnabled: (enabled: boolean) => void + setRenderedRowCount: (count: number) => void + selectSymbol: (symbol: string | null) => void + resetMarket: () => void +} + +export class TradingBenchmarkController { + readonly requestedVirtualScrollMode = ref('auto') + readonly metrics = shallowRef(initialMetrics) + readonly mountedCells = ref(0) + readonly liveComponents = ref(0) + readonly selectedSymbol = ref(null) + readonly rendererMode = ref('stable') + readonly longAnimationFramesSupported = longAnimationFramesSupported + readonly monitor = new BenchmarkMonitor() + readonly feed: MarketFeedController + readonly actions: TradingBenchmarkActions + + readonly #runtime = { + animationFrameId: 0, + longAnimationFrameObserver: null as PerformanceObserver | null, + stopObservingFeed: null as (() => void) | null, + } + + constructor(feed: MarketFeedController) { + this.feed = feed + this.actions = { + resetViewState: () => { + this.selectedSymbol.value = null + }, + setRendererMode: (mode) => { + this.rendererMode.value = mode + }, + setVirtualScrollEnabled: (enabled) => { + if ( + this.feed.instrumentCount.value >= FORCED_VIRTUALIZATION_ROW_COUNT + ) { + return + } + this.requestedVirtualScrollMode.value = enabled ? 'tanstack' : 'none' + }, + setRenderedRowCount: (count) => { + const mountedCells = count * TRADING_COLUMN_COUNT + if (mountedCells !== this.mountedCells.value) { + this.mountedCells.value = mountedCells + } + }, + selectSymbol: (symbol) => { + this.selectedSymbol.value = symbol + }, + resetMarket: () => { + this.monitor.reset() + this.metrics.value = { ...initialMetrics } + this.mountedCells.value = 0 + this.liveComponents.value = 0 + this.selectedSymbol.value = null + this.feed.actions.reset() + }, + } + } + + start(): () => void { + const longAnimationFrameObserver = longAnimationFramesSupported + ? new PerformanceObserver((entries) => { + for (const entry of entries.getEntries()) { + this.monitor.recordLongAnimationFrame( + entry.duration, + entry.startTime, + ) + } + }) + : null + + this.#runtime.longAnimationFrameObserver = longAnimationFrameObserver + this.#runtime.stopObservingFeed = this.feed.observe({ + messageReceived: () => this.monitor.recordWorkerMessage(), + mutationStarted: () => this.monitor.markCommitPending(), + batchApplied: ({ tickCount, updateCount, supersededUpdateCount }) => + this.monitor.recordBatch(tickCount, updateCount, supersededUpdateCount), + renderCommitted: () => this.monitor.recordDomCommit(), + }) + longAnimationFrameObserver?.observe({ + type: 'long-animation-frame', + }) + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + + return () => this.stop() + } + + stop(): void { + cancelAnimationFrame(this.#runtime.animationFrameId) + this.#runtime.longAnimationFrameObserver?.disconnect() + this.#runtime.stopObservingFeed?.() + this.#runtime.longAnimationFrameObserver = null + this.#runtime.stopObservingFeed = null + } + + readonly #benchmarkFrame = (now: number): void => { + this.monitor.recordAnimationFrame(now) + if (this.monitor.shouldPublish(now)) { + this.#publishMetrics(this.monitor.publish(now)) + } + this.#runtime.animationFrameId = requestAnimationFrame(this.#benchmarkFrame) + } + + #publishMetrics(metrics: FeedMetrics): void { + this.metrics.value = metrics + this.liveComponents.value = + metrics.componentsCreated - metrics.componentsDestroyed + } +} diff --git a/examples/vue/realtime-trading/src/benchmark/use-table-benchmark.ts b/examples/vue/realtime-trading/src/benchmark/use-table-benchmark.ts new file mode 100644 index 0000000000..2b8a862c06 --- /dev/null +++ b/examples/vue/realtime-trading/src/benchmark/use-table-benchmark.ts @@ -0,0 +1,29 @@ +import { onBeforeUnmount, onMounted } from 'vue' +import type { TradingBenchmarkController } from './trading-benchmark-controller' + +export function useTableBenchmark( + controller: TradingBenchmarkController, +): void { + const runtime = { mutationObserver: null as MutationObserver | null } + + onMounted(() => { + const tableBody = document.querySelector( + '.market-panel [data-trading-table] tbody', + ) + if (!tableBody) return + + controller.monitor.resetDomMutations() + runtime.mutationObserver = new MutationObserver((records) => { + controller.monitor.recordDomMutations(records.length) + }) + runtime.mutationObserver.observe(tableBody, { + attributes: true, + attributeFilter: ['class', 'style'], + characterData: true, + childList: true, + subtree: true, + }) + }) + + onBeforeUnmount(() => runtime.mutationObserver?.disconnect()) +} diff --git a/examples/vue/realtime-trading/src/feed/feed-sample-rates.ts b/examples/vue/realtime-trading/src/feed/feed-sample-rates.ts new file mode 100644 index 0000000000..17dc2015a6 --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/feed-sample-rates.ts @@ -0,0 +1,33 @@ +export const feedSampleRateOptions = [ + { label: '100', value: 100 }, + { label: '250', value: 250 }, + { label: '500', value: 500 }, + { label: '1K', value: 1_000 }, + { label: '2.5K', value: 2_500 }, + { label: '5K', value: 5_000 }, + { label: '10K', value: 10_000 }, + { label: '25K', value: 25_000 }, + { label: '50K', value: 50_000 }, + { label: '100K', value: 100_000 }, +] as const + +export function feedSampleRateIndex(rate: number): number { + return feedSampleRateOptions.reduce((closestIndex, candidate, index) => { + const closest = feedSampleRateOptions[closestIndex] + return Math.abs(candidate.value - rate) < Math.abs(closest.value - rate) + ? index + : closestIndex + }, 0) +} + +export function feedSampleRateAt(stepIndex: number): number { + const index = Math.min( + feedSampleRateOptions.length - 1, + Math.max(0, Math.round(stepIndex)), + ) + return feedSampleRateOptions[index].value +} + +export function normalizeFeedSampleRate(rate: number): number { + return feedSampleRateAt(feedSampleRateIndex(rate)) +} diff --git a/examples/vue/realtime-trading/src/feed/market-data.ts b/examples/vue/realtime-trading/src/feed/market-data.ts new file mode 100644 index 0000000000..6231865d3f --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/market-data.ts @@ -0,0 +1,38 @@ +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './worker/market-feed-protocol' + +export interface MarketQuote extends Omit { + history: ReadonlyArray +} + +export function hydrateMarketQuotes( + snapshots: Array, +): Array { + return snapshots.map((quote) => ({ + ...quote, + history: [...quote.history], + })) +} + +export function applyMarketUpdates( + quotes: Array, + updates: Array, +): Array { + const nextQuotes = [...quotes] + + for (const update of updates) { + const { index, history, ...values } = update + const previousQuote = quotes.at(index) + if (!previousQuote) continue + + nextQuotes[index] = { + ...previousQuote, + ...values, + history: history ?? previousQuote.history, + } + } + + return nextQuotes +} diff --git a/examples/vue/realtime-trading/src/feed/market-feed-config.ts b/examples/vue/realtime-trading/src/feed/market-feed-config.ts new file mode 100644 index 0000000000..9d477ed663 --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/market-feed-config.ts @@ -0,0 +1,15 @@ +export interface MarketFeedConfig { + readonly instrumentCount: number + readonly targetSamplesPerSecond: number + readonly publishIntervalMs: number + readonly updateSparklines: boolean + readonly sparklineSampleIntervalMs: number +} + +export const initialMarketFeedConfig: MarketFeedConfig = { + instrumentCount: 100, + targetSamplesPerSecond: 10_000, + publishIntervalMs: 20, + updateSparklines: true, + sparklineSampleIntervalMs: 16, +} diff --git a/examples/vue/realtime-trading/src/feed/market-feed-controller.ts b/examples/vue/realtime-trading/src/feed/market-feed-controller.ts new file mode 100644 index 0000000000..27e2a8493b --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/market-feed-controller.ts @@ -0,0 +1,217 @@ +import { ref, shallowRef } from 'vue' +import { normalizeFeedSampleRate } from './feed-sample-rates' +import { initialMarketFeedConfig } from './market-feed-config' +import { applyMarketUpdates, hydrateMarketQuotes } from './market-data' +import type { + MarketFeedCommand, + MarketFeedEvent, +} from './worker/market-feed-protocol' +import type { MarketQuote } from './market-data' + +export interface MarketFeedBatch { + tickCount: number + updateCount: number + supersededUpdateCount: number +} + +export interface MarketFeedObserver { + messageReceived?: () => void + mutationStarted?: () => void + batchApplied?: (batch: MarketFeedBatch) => void + renderCommitted?: () => void +} + +export interface MarketFeedActions { + toggle: () => void + setInstrumentCount: (count: number) => void + setTargetRate: (rate: number) => void + setPublishInterval: (intervalMs: number) => void + setSparklineUpdates: (enabled: boolean) => void + setSparklineSampleInterval: (intervalMs: number) => void + runBurst: () => void + reset: () => void +} + +export class MarketFeedController { + readonly workerReady = ref(false) + readonly running = ref(true) + readonly instrumentCount = ref(initialMarketFeedConfig.instrumentCount) + readonly targetTicksPerSecond = ref( + initialMarketFeedConfig.targetSamplesPerSecond, + ) + readonly publishIntervalMs = ref(initialMarketFeedConfig.publishIntervalMs) + readonly updateSparklines = ref(initialMarketFeedConfig.updateSparklines) + readonly sparklineSampleIntervalMs = ref( + initialMarketFeedConfig.sparklineSampleIntervalMs, + ) + readonly quotes = shallowRef>([]) + readonly actions: MarketFeedActions + readonly #observers = new Set() + readonly #runtime = { + worker: null as Worker | null, + feedSessionId: 0, + renderPending: false, + resetWaitingForCommit: false, + resetSnapshotReady: false, + quoteIndexBySymbol: new Map(), + } + + constructor() { + this.actions = { + toggle: () => { + const running = !this.running.value + this.running.value = running + this.#post({ type: 'set-running', running }) + }, + setInstrumentCount: (count) => { + this.instrumentCount.value = count + this.#resetWorker(count) + }, + setTargetRate: (rate) => { + const sampleRate = normalizeFeedSampleRate(rate) + this.targetTicksPerSecond.value = sampleRate + this.#post({ type: 'set-rate', ticksPerSecond: sampleRate }) + }, + setPublishInterval: (publishIntervalMs) => { + this.publishIntervalMs.value = publishIntervalMs + this.#post({ + type: 'set-publish-interval', + intervalMs: publishIntervalMs, + }) + }, + setSparklineUpdates: (enabled) => { + this.updateSparklines.value = enabled + this.#post({ type: 'set-sparklines', enabled }) + }, + setSparklineSampleInterval: (intervalMs) => { + this.sparklineSampleIntervalMs.value = intervalMs + this.#post({ type: 'set-sparkline-interval', intervalMs }) + }, + runBurst: () => this.#post({ type: 'burst', tickCount: 25_000 }), + reset: () => this.#resetWorker(this.instrumentCount.value), + } + } + + start(): () => void { + const worker = new Worker( + new URL('./worker/market-feed.worker.ts', import.meta.url), + { type: 'module' }, + ) + this.#runtime.worker = worker + worker.addEventListener('message', this.#handleWorkerMessage) + worker.addEventListener('error', this.#handleWorkerError) + this.#post({ + type: 'start', + rowCount: this.instrumentCount.value, + running: this.running.value, + ticksPerSecond: this.targetTicksPerSecond.value, + publishIntervalMs: this.publishIntervalMs.value, + updateSparklines: this.updateSparklines.value, + sparklineSampleIntervalMs: this.sparklineSampleIntervalMs.value, + }) + return () => this.stop() + } + + stop(): void { + this.#runtime.worker?.removeEventListener( + 'message', + this.#handleWorkerMessage, + ) + this.#runtime.worker?.removeEventListener('error', this.#handleWorkerError) + this.#runtime.worker?.terminate() + this.#runtime.worker = null + this.#observers.clear() + } + + observe(observer: MarketFeedObserver): () => void { + this.#observers.add(observer) + return () => this.#observers.delete(observer) + } + + getQuoteBySymbol( + quotes: Array, + symbol: string | null, + ): MarketQuote | null { + if (symbol === null) return null + + const index = this.#runtime.quoteIndexBySymbol.get(symbol) + return index === undefined ? null : (quotes[index] ?? null) + } + + completeRender(): void { + if (!this.#runtime.renderPending) return + + this.#runtime.renderPending = false + for (const observer of this.#observers) { + observer.renderCommitted?.() + } + if ( + this.#runtime.resetWaitingForCommit && + this.#runtime.resetSnapshotReady + ) { + this.#runtime.resetWaitingForCommit = false + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: this.running.value }) + } + } + + readonly #handleWorkerMessage = ({ + data, + }: MessageEvent): void => { + if (data.type === 'snapshot') { + this.#runtime.feedSessionId = data.sessionId + if (this.#runtime.resetWaitingForCommit) { + this.#runtime.resetSnapshotReady = true + } + this.#startMutation() + const quotes = hydrateMarketQuotes(data.quotes) + this.#runtime.quoteIndexBySymbol = new Map( + quotes.map((quote, index) => [quote.symbol, index]), + ) + this.quotes.value = quotes + this.workerReady.value = true + return + } + + if (data.sessionId !== this.#runtime.feedSessionId) return + + for (const observer of this.#observers) { + observer.messageReceived?.() + } + this.#startMutation() + this.quotes.value = applyMarketUpdates(this.quotes.value, data.updates) + const feedBatch = { + tickCount: data.tickCount, + updateCount: data.updates.length, + supersededUpdateCount: data.coalescedUpdateCount, + } + for (const observer of this.#observers) { + observer.batchApplied?.(feedBatch) + } + } + + readonly #handleWorkerError = (error: ErrorEvent): void => { + this.workerReady.value = false + this.running.value = false + console.error('Market feed worker failed', error) + } + + #resetWorker(rowCount: number): void { + this.workerReady.value = false + this.#runtime.resetWaitingForCommit = true + this.#runtime.resetSnapshotReady = false + this.#post({ type: 'set-running', running: false }) + this.#post({ type: 'reset', rowCount }) + } + + #startMutation(): void { + this.#runtime.renderPending = true + for (const observer of this.#observers) { + observer.mutationStarted?.() + } + } + + #post(command: MarketFeedCommand): void { + this.#runtime.worker?.postMessage(command) + } +} diff --git a/examples/vue/realtime-trading/src/feed/market-instruments.ts b/examples/vue/realtime-trading/src/feed/market-instruments.ts new file mode 100644 index 0000000000..ac18d14ea9 --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/market-instruments.ts @@ -0,0 +1,742 @@ +export type MarketCode = + | 'US' + | 'NL' + | 'DE' + | 'FR' + | 'IT' + | 'ES' + | 'CH' + | 'DK' + | 'SE' + | 'FI' + | 'GB' + | 'KR' + | 'JP' + | 'HK' + | 'CA' + | 'AU' + +export type BaseInstrument = readonly [ + symbol: string, + company: string, + market: MarketCode, +] + +/** + * Current S&P 500 constituent universe used by the realtime trading examples. + * + * Source snapshot: https://github.com/datasets/s-and-p-500-companies + * Retrieved: 2026-08-15. + * Multiple share classes are intentionally represented as separate instruments. + */ +export const marketInstruments = Object.freeze([ + ['MMM', '3M', 'US'], + ['AOS', 'A. O. Smith', 'US'], + ['ABT', 'Abbott Laboratories', 'US'], + ['ABBV', 'AbbVie', 'US'], + ['ACN', 'Accenture', 'US'], + ['ADBE', 'Adobe Inc.', 'US'], + ['AMD', 'Advanced Micro Devices', 'US'], + ['AES', 'AES Corporation', 'US'], + ['AFL', 'Aflac', 'US'], + ['A', 'Agilent Technologies', 'US'], + ['APD', 'Air Products', 'US'], + ['ABNB', 'Airbnb', 'US'], + ['AKAM', 'Akamai Technologies', 'US'], + ['ALB', 'Albemarle Corporation', 'US'], + ['ARE', 'Alexandria Real Estate Equities', 'US'], + ['ALGN', 'Align Technology', 'US'], + ['ALLE', 'Allegion', 'US'], + ['LNT', 'Alliant Energy', 'US'], + ['ALL', 'Allstate', 'US'], + ['GOOGL', 'Alphabet Inc. (Class A)', 'US'], + ['GOOG', 'Alphabet Inc. (Class C)', 'US'], + ['MO', 'Altria', 'US'], + ['AMZN', 'Amazon', 'US'], + ['AMCR', 'Amcor', 'US'], + ['AEE', 'Ameren', 'US'], + ['AEP', 'American Electric Power', 'US'], + ['AXP', 'American Express', 'US'], + ['AIG', 'American International Group', 'US'], + ['AMT', 'American Tower', 'US'], + ['AWK', 'American Water Works', 'US'], + ['AMP', 'Ameriprise Financial', 'US'], + ['AME', 'Ametek', 'US'], + ['AMGN', 'Amgen', 'US'], + ['APH', 'Amphenol', 'US'], + ['ADI', 'Analog Devices', 'US'], + ['AON', 'Aon plc', 'US'], + ['APA', 'APA Corporation', 'US'], + ['APO', 'Apollo Global Management', 'US'], + ['AAPL', 'Apple Inc.', 'US'], + ['AMAT', 'Applied Materials', 'US'], + ['APP', 'AppLovin', 'US'], + ['APTV', 'Aptiv', 'US'], + ['ACGL', 'Arch Capital Group', 'US'], + ['ADM', 'Archer Daniels Midland', 'US'], + ['ARES', 'Ares Management', 'US'], + ['ANET', 'Arista Networks', 'US'], + ['AJG', 'Arthur J. Gallagher & Co.', 'US'], + ['AIZ', 'Assurant', 'US'], + ['T', 'AT&T', 'US'], + ['ATO', 'Atmos Energy', 'US'], + ['ADSK', 'Autodesk', 'US'], + ['ADP', 'Automatic Data Processing', 'US'], + ['AZO', 'AutoZone', 'US'], + ['AVB', 'AvalonBay Communities', 'US'], + ['AVY', 'Avery Dennison', 'US'], + ['AXON', 'Axon Enterprise', 'US'], + ['BKR', 'Baker Hughes', 'US'], + ['BALL', 'Ball Corporation', 'US'], + ['BAC', 'Bank of America', 'US'], + ['BAX', 'Baxter International', 'US'], + ['BDX', 'Becton Dickinson', 'US'], + ['BRK.B', 'Berkshire Hathaway', 'US'], + ['BBY', 'Best Buy', 'US'], + ['TECH', 'Bio-Techne', 'US'], + ['BIIB', 'Biogen', 'US'], + ['BLK', 'BlackRock', 'US'], + ['BX', 'Blackstone Inc.', 'US'], + ['XYZ', 'Block, Inc.', 'US'], + ['BNY', 'BNY Mellon', 'US'], + ['BA', 'Boeing', 'US'], + ['BKNG', 'Booking Holdings', 'US'], + ['BSX', 'Boston Scientific', 'US'], + ['BMY', 'Bristol Myers Squibb', 'US'], + ['AVGO', 'Broadcom', 'US'], + ['BR', 'Broadridge Financial Solutions', 'US'], + ['BRO', 'Brown & Brown', 'US'], + ['BF.B', 'Brown–Forman', 'US'], + ['BLDR', 'Builders FirstSource', 'US'], + ['BG', 'Bunge Global', 'US'], + ['BXP', 'BXP, Inc.', 'US'], + ['CHRW', 'C.H. Robinson', 'US'], + ['CDNS', 'Cadence Design Systems', 'US'], + ['CPT', 'Camden Property Trust', 'US'], + ['COF', 'Capital One', 'US'], + ['CAH', 'Cardinal Health', 'US'], + ['CCL', 'Carnival Corporation', 'US'], + ['CARR', 'Carrier Global', 'US'], + ['CVNA', 'Carvana', 'US'], + ['CASY', "Casey's", 'US'], + ['CAT', 'Caterpillar Inc.', 'US'], + ['CBOE', 'Cboe Global Markets', 'US'], + ['CBRE', 'CBRE Group', 'US'], + ['CDW', 'CDW Corporation', 'US'], + ['COR', 'Cencora', 'US'], + ['CNC', 'Centene Corporation', 'US'], + ['CNP', 'CenterPoint Energy', 'US'], + ['CF', 'CF Industries', 'US'], + ['CRL', 'Charles River Laboratories', 'US'], + ['SCHW', 'Charles Schwab Corporation', 'US'], + ['CHTR', 'Charter Communications', 'US'], + ['CVX', 'Chevron Corporation', 'US'], + ['CMG', 'Chipotle Mexican Grill', 'US'], + ['CB', 'Chubb Limited', 'US'], + ['CHD', 'Church & Dwight', 'US'], + ['CIEN', 'Ciena', 'US'], + ['CI', 'Cigna', 'US'], + ['CINF', 'Cincinnati Financial', 'US'], + ['CTAS', 'Cintas', 'US'], + ['CSCO', 'Cisco', 'US'], + ['C', 'Citigroup', 'US'], + ['CFG', 'Citizens Financial Group', 'US'], + ['CLX', 'Clorox', 'US'], + ['CME', 'CME Group', 'US'], + ['CMS', 'CMS Energy', 'US'], + ['KO', 'Coca-Cola Company (The)', 'US'], + ['CTSH', 'Cognizant', 'US'], + ['COHR', 'Coherent Corp.', 'US'], + ['COIN', 'Coinbase', 'US'], + ['CL', 'Colgate-Palmolive', 'US'], + ['CMCSA', 'Comcast', 'US'], + ['FIX', 'Comfort Systems USA', 'US'], + ['COP', 'ConocoPhillips', 'US'], + ['ED', 'Consolidated Edison', 'US'], + ['STZ', 'Constellation Brands', 'US'], + ['CEG', 'Constellation Energy', 'US'], + ['COO', 'Cooper Companies (The)', 'US'], + ['CPRT', 'Copart', 'US'], + ['GLW', 'Corning Inc.', 'US'], + ['CPAY', 'Corpay', 'US'], + ['CTVA', 'Corteva', 'US'], + ['CSGP', 'CoStar Group', 'US'], + ['COST', 'Costco', 'US'], + ['CRH', 'CRH plc', 'US'], + ['CRWD', 'CrowdStrike', 'US'], + ['CCI', 'Crown Castle', 'US'], + ['CSX', 'CSX Corporation', 'US'], + ['CMI', 'Cummins', 'US'], + ['CVS', 'CVS Health', 'US'], + ['DHR', 'Danaher Corporation', 'US'], + ['DRI', 'Darden Restaurants', 'US'], + ['DDOG', 'Datadog', 'US'], + ['DVA', 'DaVita', 'US'], + ['DECK', 'Deckers Brands', 'US'], + ['DE', 'Deere & Company', 'US'], + ['DELL', 'Dell Technologies', 'US'], + ['DAL', 'Delta Air Lines', 'US'], + ['DVN', 'Devon Energy', 'US'], + ['DXCM', 'Dexcom', 'US'], + ['FANG', 'Diamondback Energy', 'US'], + ['DLR', 'Digital Realty', 'US'], + ['DG', 'Dollar General', 'US'], + ['DLTR', 'Dollar Tree', 'US'], + ['D', 'Dominion Energy', 'US'], + ['DPZ', "Domino's", 'US'], + ['DASH', 'DoorDash', 'US'], + ['DOV', 'Dover Corporation', 'US'], + ['DOW', 'Dow Inc.', 'US'], + ['DHI', 'D. R. Horton', 'US'], + ['DTE', 'DTE Energy', 'US'], + ['DUK', 'Duke Energy', 'US'], + ['DD', 'DuPont', 'US'], + ['ETN', 'Eaton Corporation', 'US'], + ['EBAY', 'eBay Inc.', 'US'], + ['ECHO', 'EchoStar', 'US'], + ['ECL', 'Ecolab', 'US'], + ['EIX', 'Edison International', 'US'], + ['EW', 'Edwards Lifesciences', 'US'], + ['ELV', 'Elevance Health', 'US'], + ['EME', 'Emcor', 'US'], + ['EMR', 'Emerson Electric', 'US'], + ['ETR', 'Entergy', 'US'], + ['EOG', 'EOG Resources', 'US'], + ['EQT', 'EQT Corporation', 'US'], + ['EFX', 'Equifax', 'US'], + ['EQIX', 'Equinix', 'US'], + ['EQR', 'Equity Residential', 'US'], + ['ERIE', 'Erie Indemnity', 'US'], + ['ESS', 'Essex Property Trust', 'US'], + ['EL', 'Estée Lauder Companies (The)', 'US'], + ['EG', 'Everest Group', 'US'], + ['EVRG', 'Evergy', 'US'], + ['ES', 'Eversource Energy', 'US'], + ['EXC', 'Exelon', 'US'], + ['EXE', 'Expand Energy', 'US'], + ['EXPE', 'Expedia Group', 'US'], + ['EXPD', 'Expeditors International', 'US'], + ['EXR', 'Extra Space Storage', 'US'], + ['XOM', 'ExxonMobil', 'US'], + ['FFIV', 'F5, Inc.', 'US'], + ['FDS', 'FactSet', 'US'], + ['FICO', 'Fair Isaac', 'US'], + ['FAST', 'Fastenal', 'US'], + ['FRT', 'Federal Realty Investment Trust', 'US'], + ['FDX', 'FedEx', 'US'], + ['FDXF', 'FedEx Freight', 'US'], + ['FERG', 'Ferguson Enterprises', 'US'], + ['FIS', 'Fidelity National Information Services', 'US'], + ['FITB', 'Fifth Third Bancorp', 'US'], + ['FSLR', 'First Solar', 'US'], + ['FE', 'FirstEnergy', 'US'], + ['FISV', 'Fiserv', 'US'], + ['FLEX', 'Flex Ltd.', 'US'], + ['F', 'Ford Motor Company', 'US'], + ['FTNT', 'Fortinet', 'US'], + ['FTV', 'Fortive', 'US'], + ['FOXA', 'Fox Corporation (Class A)', 'US'], + ['FOX', 'Fox Corporation (Class B)', 'US'], + ['BEN', 'Franklin Resources', 'US'], + ['FCX', 'Freeport-McMoRan', 'US'], + ['GRMN', 'Garmin', 'US'], + ['IT', 'Gartner', 'US'], + ['GE', 'GE Aerospace', 'US'], + ['GEHC', 'GE HealthCare', 'US'], + ['GEV', 'GE Vernova', 'US'], + ['GEN', 'Gen Digital', 'US'], + ['GNRC', 'Generac', 'US'], + ['GD', 'General Dynamics', 'US'], + ['GIS', 'General Mills', 'US'], + ['GM', 'General Motors', 'US'], + ['GPC', 'Genuine Parts Company', 'US'], + ['GILD', 'Gilead Sciences', 'US'], + ['GPN', 'Global Payments', 'US'], + ['GL', 'Globe Life', 'US'], + ['GDDY', 'GoDaddy', 'US'], + ['GS', 'Goldman Sachs', 'US'], + ['HAL', 'Halliburton', 'US'], + ['HIG', 'Hartford (The)', 'US'], + ['HAS', 'Hasbro', 'US'], + ['HCA', 'HCA Healthcare', 'US'], + ['DOC', 'Healthpeak Properties', 'US'], + ['HSIC', 'Henry Schein', 'US'], + ['HSY', 'Hershey Company (The)', 'US'], + ['HPE', 'Hewlett Packard Enterprise', 'US'], + ['HLT', 'Hilton Worldwide', 'US'], + ['HD', 'Home Depot (The)', 'US'], + ['HONA', 'Honeywell Aerospace', 'US'], + ['HON', 'Honeywell Technologies', 'US'], + ['HRL', 'Hormel Foods', 'US'], + ['HST', 'Host Hotels & Resorts', 'US'], + ['HWM', 'Howmet Aerospace', 'US'], + ['HPQ', 'HP Inc.', 'US'], + ['HUBB', 'Hubbell Incorporated', 'US'], + ['HUM', 'Humana', 'US'], + ['HBAN', 'Huntington Bancshares', 'US'], + ['HII', 'Huntington Ingalls Industries', 'US'], + ['IBM', 'IBM', 'US'], + ['IEX', 'IDEX Corporation', 'US'], + ['IDXX', 'Idexx Laboratories', 'US'], + ['ITW', 'Illinois Tool Works', 'US'], + ['INCY', 'Incyte', 'US'], + ['IR', 'Ingersoll Rand', 'US'], + ['PODD', 'Insulet Corporation', 'US'], + ['INTC', 'Intel', 'US'], + ['IBKR', 'Interactive Brokers', 'US'], + ['ICE', 'Intercontinental Exchange', 'US'], + ['IFF', 'International Flavors & Fragrances', 'US'], + ['IP', 'International Paper', 'US'], + ['INTU', 'Intuit', 'US'], + ['ISRG', 'Intuitive Surgical', 'US'], + ['IVZ', 'Invesco', 'US'], + ['INVH', 'Invitation Homes', 'US'], + ['IQV', 'IQVIA', 'US'], + ['IRM', 'Iron Mountain', 'US'], + ['JBHT', 'J.B. Hunt', 'US'], + ['JBL', 'Jabil', 'US'], + ['JKHY', 'Jack Henry & Associates', 'US'], + ['J', 'Jacobs Solutions', 'US'], + ['JNJ', 'Johnson & Johnson', 'US'], + ['JCI', 'Johnson Controls', 'US'], + ['JPM', 'JPMorgan Chase', 'US'], + ['KVUE', 'Kenvue', 'US'], + ['KDP', 'Keurig Dr Pepper', 'US'], + ['KEY', 'KeyCorp', 'US'], + ['KEYS', 'Keysight Technologies', 'US'], + ['KMB', 'Kimberly-Clark', 'US'], + ['KIM', 'Kimco Realty', 'US'], + ['KMI', 'Kinder Morgan', 'US'], + ['KKR', 'KKR & Co.', 'US'], + ['KLAC', 'KLA Corporation', 'US'], + ['KHC', 'Kraft Heinz', 'US'], + ['KR', 'Kroger', 'US'], + ['LHX', 'L3Harris', 'US'], + ['LH', 'Labcorp', 'US'], + ['LRCX', 'Lam Research', 'US'], + ['LVS', 'Las Vegas Sands', 'US'], + ['LDOS', 'Leidos', 'US'], + ['LEN', 'Lennar', 'US'], + ['LII', 'Lennox International', 'US'], + ['LLY', 'Lilly (Eli)', 'US'], + ['LIN', 'Linde plc', 'US'], + ['LYV', 'Live Nation Entertainment', 'US'], + ['LMT', 'Lockheed Martin', 'US'], + ['L', 'Loews Corporation', 'US'], + ['LOW', "Lowe's", 'US'], + ['LULU', 'Lululemon Athletica', 'US'], + ['LITE', 'Lumentum', 'US'], + ['LYB', 'LyondellBasell', 'US'], + ['MTB', 'M&T Bank', 'US'], + ['MPC', 'Marathon Petroleum', 'US'], + ['MAR', 'Marriott International', 'US'], + ['MRSH', 'Marsh McLennan', 'US'], + ['MLM', 'Martin Marietta Materials', 'US'], + ['MRVL', 'Marvell Technology', 'US'], + ['MAS', 'Masco', 'US'], + ['MA', 'Mastercard', 'US'], + ['MKC', 'McCormick & Company', 'US'], + ['MCD', "McDonald's", 'US'], + ['MCK', 'McKesson Corporation', 'US'], + ['MDT', 'Medtronic', 'US'], + ['MRK', 'Merck & Co.', 'US'], + ['META', 'Meta Platforms', 'US'], + ['MET', 'MetLife', 'US'], + ['MTD', 'Mettler Toledo', 'US'], + ['MGM', 'MGM Resorts', 'US'], + ['MCHP', 'Microchip Technology', 'US'], + ['MU', 'Micron Technology', 'US'], + ['MSFT', 'Microsoft', 'US'], + ['MAA', 'Mid-America Apartment Communities', 'US'], + ['MRNA', 'Moderna', 'US'], + ['TAP', 'Molson Coors Beverage Company', 'US'], + ['MDLZ', 'Mondelez International', 'US'], + ['MPWR', 'Monolithic Power Systems', 'US'], + ['MNST', 'Monster Beverage', 'US'], + ['MCO', "Moody's Corporation", 'US'], + ['MS', 'Morgan Stanley', 'US'], + ['MOS', 'Mosaic Company (The)', 'US'], + ['MSI', 'Motorola Solutions', 'US'], + ['MSCI', 'MSCI', 'US'], + ['NDAQ', 'Nasdaq, Inc.', 'US'], + ['NTAP', 'NetApp', 'US'], + ['NFLX', 'Netflix', 'US'], + ['NEM', 'Newmont', 'US'], + ['NWSA', 'News Corp (Class A)', 'US'], + ['NWS', 'News Corp (Class B)', 'US'], + ['NEE', 'NextEra Energy', 'US'], + ['NKE', 'Nike, Inc.', 'US'], + ['NI', 'NiSource', 'US'], + ['NDSN', 'Nordson Corporation', 'US'], + ['NSC', 'Norfolk Southern', 'US'], + ['NTRS', 'Northern Trust', 'US'], + ['NOC', 'Northrop Grumman', 'US'], + ['NCLH', 'Norwegian Cruise Line Holdings', 'US'], + ['NRG', 'NRG Energy', 'US'], + ['NUE', 'Nucor', 'US'], + ['NVDA', 'Nvidia', 'US'], + ['NVR', 'NVR, Inc.', 'US'], + ['NXPI', 'NXP Semiconductors', 'US'], + ['ORLY', "O'Reilly Automotive", 'US'], + ['OXY', 'Occidental Petroleum', 'US'], + ['ODFL', 'Old Dominion', 'US'], + ['OMC', 'Omnicom Group', 'US'], + ['ON', 'ON Semiconductor', 'US'], + ['OKE', 'Oneok', 'US'], + ['ORCL', 'Oracle Corporation', 'US'], + ['OTIS', 'Otis Worldwide', 'US'], + ['PCAR', 'Paccar', 'US'], + ['PKG', 'Packaging Corporation of America', 'US'], + ['PLTR', 'Palantir Technologies', 'US'], + ['PANW', 'Palo Alto Networks', 'US'], + ['PSKY', 'Paramount Skydance Corporation', 'US'], + ['PH', 'Parker Hannifin', 'US'], + ['PAYX', 'Paychex', 'US'], + ['PYPL', 'PayPal', 'US'], + ['PNR', 'Pentair', 'US'], + ['PEP', 'PepsiCo', 'US'], + ['PFE', 'Pfizer', 'US'], + ['PCG', 'PG&E Corporation', 'US'], + ['PM', 'Philip Morris International', 'US'], + ['PSX', 'Phillips 66', 'US'], + ['PNW', 'Pinnacle West Capital', 'US'], + ['PNC', 'PNC Financial Services', 'US'], + ['PPG', 'PPG Industries', 'US'], + ['PPL', 'PPL Corporation', 'US'], + ['PFG', 'Principal Financial Group', 'US'], + ['PG', 'Procter & Gamble', 'US'], + ['PGR', 'Progressive Corporation', 'US'], + ['PLD', 'Prologis', 'US'], + ['PRU', 'Prudential Financial', 'US'], + ['PEG', 'Public Service Enterprise Group', 'US'], + ['PTC', 'PTC Inc.', 'US'], + ['PSA', 'Public Storage', 'US'], + ['PHM', 'PulteGroup', 'US'], + ['PWR', 'Quanta Services', 'US'], + ['QCOM', 'Qualcomm', 'US'], + ['DGX', 'Quest Diagnostics', 'US'], + ['Q', 'Qnity Electronics', 'US'], + ['RL', 'Ralph Lauren Corporation', 'US'], + ['RJF', 'Raymond James Financial', 'US'], + ['RTX', 'RTX Corporation', 'US'], + ['O', 'Realty Income', 'US'], + ['REG', 'Regency Centers', 'US'], + ['REGN', 'Regeneron Pharmaceuticals', 'US'], + ['RF', 'Regions Financial Corporation', 'US'], + ['RSG', 'Republic Services', 'US'], + ['RMD', 'ResMed|', 'US'], + ['RVTY', 'Revvity', 'US'], + ['HOOD', 'Robinhood Markets', 'US'], + ['ROK', 'Rockwell Automation', 'US'], + ['ROL', 'Rollins, Inc.', 'US'], + ['ROP', 'Roper Technologies', 'US'], + ['ROST', 'Ross Stores', 'US'], + ['RCL', 'Royal Caribbean Group', 'US'], + ['SPGI', 'S&P Global', 'US'], + ['CRM', 'Salesforce', 'US'], + ['SNDK', 'Sandisk', 'US'], + ['SBAC', 'SBA Communications', 'US'], + ['SLB', 'Schlumberger', 'US'], + ['STX', 'Seagate Technology', 'US'], + ['SRE', 'Sempra', 'US'], + ['NOW', 'ServiceNow', 'US'], + ['SHW', 'Sherwin-Williams', 'US'], + ['SPG', 'Simon Property Group', 'US'], + ['SWKS', 'Skyworks Solutions', 'US'], + ['SJM', 'J.M. Smucker Company (The)', 'US'], + ['SW', 'Smurfit Westrock', 'US'], + ['SNA', 'Snap-on', 'US'], + ['SOLV', 'Solventum', 'US'], + ['SO', 'Southern Company', 'US'], + ['LUV', 'Southwest Airlines', 'US'], + ['SWK', 'Stanley Black & Decker', 'US'], + ['SBUX', 'Starbucks', 'US'], + ['STT', 'State Street Corporation', 'US'], + ['STLD', 'Steel Dynamics', 'US'], + ['STE', 'Steris', 'US'], + ['SYK', 'Stryker Corporation', 'US'], + ['SMCI', 'Supermicro', 'US'], + ['SYF', 'Synchrony Financial', 'US'], + ['SNPS', 'Synopsys', 'US'], + ['SYY', 'Sysco', 'US'], + ['TMUS', 'T-Mobile US', 'US'], + ['TROW', 'T. Rowe Price', 'US'], + ['TTWO', 'Take-Two Interactive', 'US'], + ['TPR', 'Tapestry, Inc.', 'US'], + ['TRGP', 'Targa Resources', 'US'], + ['TGT', 'Target Corporation', 'US'], + ['TEL', 'TE Connectivity', 'US'], + ['TDY', 'Teledyne Technologies', 'US'], + ['TER', 'Teradyne', 'US'], + ['TSLA', 'Tesla, Inc.', 'US'], + ['TXN', 'Texas Instruments', 'US'], + ['TPL', 'Texas Pacific Land Corporation', 'US'], + ['TXT', 'Textron', 'US'], + ['TMO', 'Thermo Fisher Scientific', 'US'], + ['TJX', 'TJX Companies', 'US'], + ['TKO', 'TKO Group Holdings', 'US'], + ['TTD', 'Trade Desk (The)', 'US'], + ['TSCO', 'Tractor Supply', 'US'], + ['TT', 'Trane Technologies', 'US'], + ['TDG', 'TransDigm Group', 'US'], + ['TRV', 'Travelers Companies (The)', 'US'], + ['TRMB', 'Trimble Inc.', 'US'], + ['TFC', 'Truist Financial', 'US'], + ['TYL', 'Tyler Technologies', 'US'], + ['TSN', 'Tyson Foods', 'US'], + ['USB', 'U.S. Bancorp', 'US'], + ['UBER', 'Uber', 'US'], + ['UDR', 'UDR, Inc.', 'US'], + ['ULTA', 'Ulta Beauty', 'US'], + ['UNP', 'Union Pacific Corporation', 'US'], + ['UAL', 'United Airlines Holdings', 'US'], + ['UPS', 'United Parcel Service', 'US'], + ['URI', 'United Rentals', 'US'], + ['UNH', 'UnitedHealth Group', 'US'], + ['UHS', 'Universal Health Services', 'US'], + ['VLO', 'Valero Energy', 'US'], + ['VEEV', 'Veeva Systems', 'US'], + ['VTR', 'Ventas', 'US'], + ['VLTO', 'Veralto', 'US'], + ['VRSN', 'Verisign', 'US'], + ['VRSK', 'Verisk Analytics', 'US'], + ['VZ', 'Verizon', 'US'], + ['VRTX', 'Vertex Pharmaceuticals', 'US'], + ['VRT', 'Vertiv', 'US'], + ['VTRS', 'Viatris', 'US'], + ['VICI', 'Vici Properties', 'US'], + ['V', 'Visa Inc.', 'US'], + ['VST', 'Vistra Corp.', 'US'], + ['VMC', 'Vulcan Materials Company', 'US'], + ['WRB', 'W. R. Berkley Corporation', 'US'], + ['GWW', 'W. W. Grainger', 'US'], + ['WAB', 'Wabtec', 'US'], + ['WMT', 'Walmart', 'US'], + ['DIS', 'Walt Disney Company (The)', 'US'], + ['WBD', 'Warner Bros. Discovery', 'US'], + ['WM', 'Waste Management', 'US'], + ['WAT', 'Waters Corporation', 'US'], + ['WEC', 'WEC Energy Group', 'US'], + ['WFC', 'Wells Fargo', 'US'], + ['WELL', 'Welltower', 'US'], + ['WST', 'West Pharmaceutical Services', 'US'], + ['WDC', 'Western Digital', 'US'], + ['WY', 'Weyerhaeuser', 'US'], + ['WSM', 'Williams-Sonoma, Inc.', 'US'], + ['WMB', 'Williams Companies', 'US'], + ['WTW', 'Willis Towers Watson', 'US'], + ['WDAY', 'Workday, Inc.', 'US'], + ['WYNN', 'Wynn Resorts', 'US'], + ['XEL', 'Xcel Energy', 'US'], + ['XYL', 'Xylem Inc.', 'US'], + ['YUM', 'Yum! Brands', 'US'], + ['ZBRA', 'Zebra Technologies', 'US'], + ['ZBH', 'Zimmer Biomet', 'US'], + ['ZTS', 'Zoetis', 'US'], +]) + +/** + * Representative liquid international listings used to diversify the fixture. + * Symbols use their local-market form and the third tuple value is an ISO-style + * country/market code, rather than a trading currency or exchange MIC. + * + * References checked 2026-08-15: STOXX Europe 50, KRX KOSPI market, + * JPX TOPIX Core30, and HKEX index constituent materials. + */ +const europeanInstruments = Object.freeze([ + ['ASML', 'ASML Holding', 'NL'], + ['INGA', 'ING Group', 'NL'], + ['ADYEN', 'Adyen', 'NL'], + ['PHIA', 'Philips', 'NL'], + ['HEIA', 'Heineken', 'NL'], + ['SAP', 'SAP', 'DE'], + ['SIE', 'Siemens', 'DE'], + ['ALV', 'Allianz', 'DE'], + ['DTE', 'Deutsche Telekom', 'DE'], + ['MBG', 'Mercedes-Benz Group', 'DE'], + ['BMW', 'BMW', 'DE'], + ['BAS', 'BASF', 'DE'], + ['MUV2', 'Munich Re', 'DE'], + ['VOW3', 'Volkswagen Preference', 'DE'], + ['IFX', 'Infineon Technologies', 'DE'], + ['MC', 'LVMH', 'FR'], + ['OR', "L'Oréal", 'FR'], + ['AIR', 'Airbus', 'FR'], + ['SU', 'Schneider Electric', 'FR'], + ['TTE', 'TotalEnergies', 'FR'], + ['SAN', 'Sanofi', 'FR'], + ['BNP', 'BNP Paribas', 'FR'], + ['CS', 'AXA', 'FR'], + ['DG', 'Vinci', 'FR'], + ['ENEL', 'Enel', 'IT'], + ['ENI', 'Eni', 'IT'], + ['ISP', 'Intesa Sanpaolo', 'IT'], + ['UCG', 'UniCredit', 'IT'], + ['STLAM', 'Stellantis', 'IT'], + ['SAN', 'Banco Santander', 'ES'], + ['IBE', 'Iberdrola', 'ES'], + ['ITX', 'Inditex', 'ES'], + ['BBVA', 'BBVA', 'ES'], + ['NESN', 'Nestlé', 'CH'], + ['ROG', 'Roche Holding', 'CH'], + ['NOVN', 'Novartis', 'CH'], + ['UBSG', 'UBS Group', 'CH'], + ['NOVO-B', 'Novo Nordisk', 'DK'], + ['MAERSK-B', 'A.P. Moller - Maersk', 'DK'], + ['VOLV-B', 'Volvo', 'SE'], + ['ERIC-B', 'Ericsson', 'SE'], + ['NOKIA', 'Nokia', 'FI'], + ['SHEL', 'Shell', 'GB'], + ['AZN', 'AstraZeneca', 'GB'], + ['HSBA', 'HSBC Holdings', 'GB'], + ['ULVR', 'Unilever', 'GB'], + ['LSEG', 'London Stock Exchange Group', 'GB'], +]) + +const europeanMarketCodes = Object.freeze([ + 'NL', + 'DE', + 'FR', + 'IT', + 'ES', + 'CH', + 'DK', + 'SE', + 'FI', + 'GB', +]) + +const europeanMarketGroups = Object.freeze( + europeanMarketCodes.map((market) => + europeanInstruments.filter(([, , instrumentMarket]) => + Object.is(instrumentMarket, market), + ), + ), +) + +const interleavedEuropeanInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...europeanMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + europeanMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const southKoreanInstruments = Object.freeze([ + ['005930', 'Samsung Electronics', 'KR'], + ['000660', 'SK hynix', 'KR'], + ['373220', 'LG Energy Solution', 'KR'], + ['207940', 'Samsung Biologics', 'KR'], + ['005380', 'Hyundai Motor', 'KR'], + ['000270', 'Kia', 'KR'], + ['068270', 'Celltrion', 'KR'], + ['105560', 'KB Financial Group', 'KR'], + ['055550', 'Shinhan Financial Group', 'KR'], + ['035420', 'NAVER', 'KR'], + ['035720', 'Kakao', 'KR'], + ['006400', 'Samsung SDI', 'KR'], + ['051910', 'LG Chem', 'KR'], + ['012330', 'Hyundai Mobis', 'KR'], + ['066570', 'LG Electronics', 'KR'], +]) + +const japaneseInstruments = Object.freeze([ + ['8306', 'Mitsubishi UFJ Financial Group', 'JP'], + ['7203', 'Toyota Motor', 'JP'], + ['6501', 'Hitachi', 'JP'], + ['8316', 'Sumitomo Mitsui Financial Group', 'JP'], + ['6758', 'Sony Group', 'JP'], + ['8058', 'Mitsubishi Corporation', 'JP'], + ['9984', 'SoftBank Group', 'JP'], + ['8035', 'Tokyo Electron', 'JP'], + ['8411', 'Mizuho Financial Group', 'JP'], + ['8031', 'Mitsui & Co.', 'JP'], + ['6861', 'Keyence', 'JP'], + ['7974', 'Nintendo', 'JP'], + ['6098', 'Recruit Holdings', 'JP'], + ['9432', 'Nippon Telegraph and Telephone', 'JP'], + ['4063', 'Shin-Etsu Chemical', 'JP'], +]) + +const hongKongInstruments = Object.freeze([ + ['0700', 'Tencent Holdings', 'HK'], + ['9988', 'Alibaba Group', 'HK'], + ['1810', 'Xiaomi', 'HK'], + ['3690', 'Meituan', 'HK'], + ['0941', 'China Mobile', 'HK'], + ['1211', 'BYD Company', 'HK'], + ['1299', 'AIA Group', 'HK'], + ['0388', 'Hong Kong Exchanges and Clearing', 'HK'], + ['0005', 'HSBC Holdings', 'HK'], + ['2318', 'Ping An Insurance', 'HK'], +]) + +const canadianInstruments = Object.freeze([ + ['RY', 'Royal Bank of Canada', 'CA'], + ['TD', 'Toronto-Dominion Bank', 'CA'], + ['SHOP', 'Shopify', 'CA'], + ['ENB', 'Enbridge', 'CA'], + ['CNR', 'Canadian National Railway', 'CA'], + ['CP', 'Canadian Pacific Kansas City', 'CA'], + ['BMO', 'Bank of Montreal', 'CA'], + ['CSU', 'Constellation Software', 'CA'], +]) + +const australianInstruments = Object.freeze([ + ['BHP', 'BHP Group', 'AU'], + ['CBA', 'Commonwealth Bank of Australia', 'AU'], + ['CSL', 'CSL', 'AU'], + ['NAB', 'National Australia Bank', 'AU'], + ['WBC', 'Westpac Banking Corporation', 'AU'], + ['ANZ', 'ANZ Group Holdings', 'AU'], + ['WES', 'Wesfarmers', 'AU'], + ['MQG', 'Macquarie Group', 'AU'], +]) + +const internationalMarketGroups = Object.freeze([ + interleavedEuropeanInstruments, + southKoreanInstruments, + japaneseInstruments, + hongKongInstruments, + canadianInstruments, + australianInstruments, +]) + +export const internationalInstruments = Object.freeze( + Array.from( + { + length: Math.max( + ...internationalMarketGroups.map((instruments) => instruments.length), + ), + }, + (_, index) => + internationalMarketGroups + .map((instruments) => instruments[index]) + .filter(Boolean), + ).flat(), +) + +const INTERNATIONAL_INSERTION_INTERVAL = 3 + +/** + * U.S. and international instruments are interleaved so ordinary benchmark + * sizes exercise multiple symbol formats and market labels. + */ +export const globalInstruments = Object.freeze( + marketInstruments.flatMap((instrument, index) => { + const internationalInstrument = + index % INTERNATIONAL_INSERTION_INTERVAL === 0 + ? internationalInstruments[index / INTERNATIONAL_INSERTION_INTERVAL] + : undefined + + return internationalInstrument + ? [instrument, internationalInstrument] + : [instrument] + }), +) as unknown as ReadonlyArray diff --git a/examples/vue/realtime-trading/src/feed/worker/market-feed-engine.ts b/examples/vue/realtime-trading/src/feed/worker/market-feed-engine.ts new file mode 100644 index 0000000000..fbd9c369c0 --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/worker/market-feed-engine.ts @@ -0,0 +1,177 @@ +import { globalInstruments } from '../market-instruments.ts' +import type { + MarketQuoteSnapshot, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const INITIAL_MARKET_SEED = 0x4d41524b +const LIVE_FEED_SEED = 0x5449434b + +export class MarketFeedEngine { + #quotes: Array = [] + #lastHistorySampledAt = new Uint32Array(0) + #random = createRandom(2_026) + #rowCursor = 0 + #tickIndex = 0 + + reset(count: number): Array { + const random = createRandom(INITIAL_MARKET_SEED ^ count) + const createdAt = Date.now() + + this.#quotes = Array.from({ length: count }, (_, index) => { + const [baseSymbol, company, market] = + globalInstruments[index % globalInstruments.length] + const series = Math.floor(index / globalInstruments.length) + const symbol = series === 0 ? baseSymbol : `${baseSymbol}${series}` + const previousClose = roundPrice(20 + random() * 480) + const open = roundPrice(previousClose * (1 + (random() - 0.5) * 0.016)) + const spread = Math.max(0.01, open * (0.0002 + random() * 0.0004)) + const history = Array.from({ length: 24 }, (_, historyIndex) => + roundPrice( + open * + (1 + Math.sin(historyIndex / 4) * 0.002 + (random() - 0.5) * 0.001), + ), + ) + const volume = Math.floor(50_000 + random() * 2_000_000) + const lastSize = Math.floor(10 + random() * 5_000) + + return { + id: `instrument-${index}`, + symbol, + company, + venue: market, + previousClose, + open, + high: open, + low: open, + price: open, + bid: roundPrice(open - spread / 2), + ask: roundPrice(open + spread / 2), + bidSize: Math.floor(100 + random() * 25_000), + askSize: Math.floor(100 + random() * 25_000), + lastSize, + lastMove: 0, + lastUpdatedAt: createdAt, + volume, + turnover: roundMoney(open * volume), + history, + } + }) + + this.#random = createRandom(LIVE_FEED_SEED ^ count) + this.#rowCursor = 0 + this.#lastHistorySampledAt = new Uint32Array(count) + this.#lastHistorySampledAt.fill(createdAt >>> 0) + + return this.#quotes.map((quote) => ({ + ...quote, + history: [...quote.history], + })) + } + + applyTicks( + tickCount: number, + updateSparklines: boolean, + sparklineSampleIntervalMs: number, + ): Array { + if (this.#quotes.length === 0 || tickCount <= 0) return [] + + const updatedAt = Date.now() + const sampledAt = updatedAt >>> 0 + const sampleIntervalMs = Math.max(16, sparklineSampleIntervalMs) + const updatedQuotes = new Map() + const stride = 97 + + this.#tickIndex = 0 + while (this.#tickIndex < tickCount) { + this.#rowCursor = (this.#rowCursor + stride) % this.#quotes.length + const quote = this.#quotes[this.#rowCursor] + const lastSampledAt = this.#lastHistorySampledAt[this.#rowCursor] + const shouldUpdateHistory = + updateSparklines && + (sampledAt - lastSampledAt) >>> 0 >= sampleIntervalMs + + if (shouldUpdateHistory) { + this.#lastHistorySampledAt[this.#rowCursor] = sampledAt + } + + this.#applyTick(quote, shouldUpdateHistory, updatedAt) + + const previousUpdate = updatedQuotes.get(this.#rowCursor) + updatedQuotes.set(this.#rowCursor, { + index: this.#rowCursor, + price: quote.price, + bid: quote.bid, + ask: quote.ask, + bidSize: quote.bidSize, + askSize: quote.askSize, + lastSize: quote.lastSize, + lastMove: quote.lastMove, + lastUpdatedAt: quote.lastUpdatedAt, + high: quote.high, + low: quote.low, + volume: quote.volume, + turnover: quote.turnover, + ...(shouldUpdateHistory || previousUpdate?.history + ? { history: [...quote.history] } + : {}), + }) + this.#tickIndex++ + } + + return [...updatedQuotes.values()] + } + + #applyTick( + quote: MarketQuoteSnapshot, + updateHistory: boolean, + updatedAt: number, + ): void { + const previousPrice = quote.price + const volatility = 0.00015 + this.#random() * 0.0012 + const move = previousPrice * (this.#random() - 0.495) * volatility + const nextPrice = roundPrice(Math.max(0.1, previousPrice + move)) + const spread = Math.max( + 0.01, + nextPrice * (0.00015 + this.#random() * 0.0005), + ) + + quote.lastMove = nextPrice - previousPrice + quote.price = nextPrice + quote.high = Math.max(quote.high, nextPrice) + quote.low = Math.min(quote.low, nextPrice) + quote.bid = roundPrice(nextPrice - spread / 2) + quote.ask = roundPrice(nextPrice + spread / 2) + quote.bidSize = Math.floor(100 + this.#random() * 25_000) + quote.askSize = Math.floor(100 + this.#random() * 25_000) + quote.lastSize = Math.floor(10 + this.#random() * 5_000) + quote.lastUpdatedAt = updatedAt + quote.volume += quote.lastSize + quote.turnover = roundMoney(quote.turnover + nextPrice * quote.lastSize) + + if (updateHistory) { + quote.history = [...quote.history.slice(-23), nextPrice] + } + } +} + +function createRandom(seed: number): () => number { + const runtime = { state: seed >>> 0 } + return () => { + runtime.state += 0x6d2b79f5 + const stateValue = runtime.state + const firstMix = Math.imul(stateValue ^ (stateValue >>> 15), stateValue | 1) + const secondMix = + firstMix + Math.imul(firstMix ^ (firstMix >>> 7), firstMix | 61) + const value = firstMix ^ secondMix + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296 + } +} + +function roundPrice(value: number): number { + return Math.round(value * 100) / 100 +} + +function roundMoney(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/examples/vue/realtime-trading/src/feed/worker/market-feed-protocol.ts b/examples/vue/realtime-trading/src/feed/worker/market-feed-protocol.ts new file mode 100644 index 0000000000..21997ec89c --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/worker/market-feed-protocol.ts @@ -0,0 +1,70 @@ +export interface MarketQuoteSnapshot { + id: string + symbol: string + company: string + venue: string + previousClose: number + open: number + high: number + low: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + volume: number + turnover: number + history: Array +} + +export interface MarketQuoteUpdate { + index: number + price: number + bid: number + ask: number + bidSize: number + askSize: number + lastSize: number + lastMove: number + lastUpdatedAt: number + high: number + low: number + volume: number + turnover: number + history?: Array +} + +export type MarketFeedCommand = + | { + type: 'start' + rowCount: number + running: boolean + ticksPerSecond: number + publishIntervalMs: number + updateSparklines: boolean + sparklineSampleIntervalMs: number + } + | { type: 'set-running'; running: boolean } + | { type: 'set-rate'; ticksPerSecond: number } + | { type: 'set-publish-interval'; intervalMs: number } + | { type: 'set-sparklines'; enabled: boolean } + | { type: 'set-sparkline-interval'; intervalMs: number } + | { type: 'reset'; rowCount: number } + | { type: 'burst'; tickCount: number } + +export type MarketFeedEvent = + | { + type: 'snapshot' + sessionId: number + quotes: Array + } + | { + type: 'updates' + sessionId: number + tickCount: number + coalescedUpdateCount: number + updates: Array + } diff --git a/examples/vue/realtime-trading/src/feed/worker/market-feed.worker.ts b/examples/vue/realtime-trading/src/feed/worker/market-feed.worker.ts new file mode 100644 index 0000000000..2b7a642fd9 --- /dev/null +++ b/examples/vue/realtime-trading/src/feed/worker/market-feed.worker.ts @@ -0,0 +1,143 @@ +import { initialMarketFeedConfig } from '../market-feed-config' +import { MarketFeedEngine } from './market-feed-engine' +import type { + MarketFeedCommand, + MarketFeedEvent, + MarketQuoteUpdate, +} from './market-feed-protocol' + +const engine = new MarketFeedEngine() +const pendingUpdates = new Map() +const runtime = { + sessionId: 0, + pendingTickCount: 0, + pendingCoalescedUpdateCount: 0, + initialized: false, + running: true, + ticksPerSecond: initialMarketFeedConfig.targetSamplesPerSecond, + publishIntervalMs: initialMarketFeedConfig.publishIntervalMs, + publishTimerId: null as ReturnType | null, + updateSparklines: initialMarketFeedConfig.updateSparklines, + sparklineSampleIntervalMs: initialMarketFeedConfig.sparklineSampleIntervalMs, + tickBudget: 0, + lastTickAt: performance.now(), +} + +addEventListener('message', ({ data }: MessageEvent) => { + switch (data.type) { + case 'start': + runtime.running = data.running + runtime.ticksPerSecond = data.ticksPerSecond + runtime.publishIntervalMs = data.publishIntervalMs + runtime.updateSparklines = data.updateSparklines + runtime.sparklineSampleIntervalMs = data.sparklineSampleIntervalMs + restartPublishTimer() + reset(data.rowCount) + break + case 'set-running': + runtime.running = data.running + break + case 'set-rate': + runtime.ticksPerSecond = data.ticksPerSecond + break + case 'set-publish-interval': + runtime.publishIntervalMs = Math.max(4, data.intervalMs) + restartPublishTimer() + break + case 'set-sparklines': + runtime.updateSparklines = data.enabled + break + case 'set-sparkline-interval': + runtime.sparklineSampleIntervalMs = Math.max(16, data.intervalMs) + break + case 'reset': + reset(data.rowCount) + break + case 'burst': + produceTicks(data.tickCount) + flush() + break + } +}) + +setInterval(() => { + const now = performance.now() + const elapsed = Math.min(100, Math.max(0, now - runtime.lastTickAt)) + runtime.lastTickAt = now + + if (runtime.initialized && runtime.running) { + runtime.tickBudget += (runtime.ticksPerSecond * elapsed) / 1_000 + const tickCount = Math.floor(runtime.tickBudget) + runtime.tickBudget -= tickCount + produceTicks(tickCount) + } +}, 16) + +restartPublishTimer() + +function reset(rowCount: number): void { + runtime.initialized = true + runtime.sessionId++ + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + runtime.tickBudget = 0 + runtime.lastTickAt = performance.now() + + post({ + type: 'snapshot', + sessionId: runtime.sessionId, + quotes: engine.reset(rowCount), + }) +} + +function produceTicks(tickCount: number): void { + if (!runtime.initialized || tickCount <= 0) return + + runtime.pendingTickCount += tickCount + for (const update of engine.applyTicks( + tickCount, + runtime.updateSparklines, + runtime.sparklineSampleIntervalMs, + )) { + const previousUpdate = pendingUpdates.get(update.index) + if (previousUpdate) runtime.pendingCoalescedUpdateCount++ + pendingUpdates.set(update.index, { + ...update, + ...(update.history || !previousUpdate?.history + ? {} + : { history: previousUpdate.history }), + }) + } +} + +function flush(): void { + if (runtime.pendingTickCount === 0) return + + const message: MarketFeedEvent = { + type: 'updates', + sessionId: runtime.sessionId, + tickCount: runtime.pendingTickCount, + coalescedUpdateCount: runtime.pendingCoalescedUpdateCount, + updates: [...pendingUpdates.values()], + } + + runtime.pendingTickCount = 0 + runtime.pendingCoalescedUpdateCount = 0 + pendingUpdates.clear() + post(message) +} + +function restartPublishTimer(): void { + if (runtime.publishTimerId !== null) { + clearInterval(runtime.publishTimerId) + } + runtime.publishTimerId = setInterval( + flush, + Math.max(4, runtime.publishIntervalMs), + ) +} + +function post(event: MarketFeedEvent): void { + postMessage(event) +} diff --git a/examples/vue/realtime-trading/src/index.css b/examples/vue/realtime-trading/src/index.css new file mode 100644 index 0000000000..397c86fc54 --- /dev/null +++ b/examples/vue/realtime-trading/src/index.css @@ -0,0 +1,1058 @@ +:root { + color-scheme: dark; + --font-ui: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace; + font-family: var(--font-ui); + font-synthesis: none; + --background: #050505; + --panel: #080808; + --panel-raised: #111111; + --panel-hover: #181818; + --surface-bar: #080808; + --surface-toolbar: #0d0d0d; + --surface-table: #050505; + --surface-header: #101010; + --surface-control: #151515; + --border: #303030; + --border-soft: #242424; + --border-strong: #4a4a4a; + --text: #e7ebf0; + --text-strong: #ffffff; + --text-inverse: #f7f9fb; + --muted: #737982; + --blue: #62a9ff; + --blue-soft: #a9d0ff; + --green: #42e488; + --red: #ff646b; + --amber: #dfb465; + --depth-bid: #315774; + --depth-ask: #634049; + --row-alt: rgb(255 255 255 / 1.8%); + --row-selected: rgb(66 132 214 / 16%); + --control-border: #354456; + --control-hover: #192431; + --sidebar-width: 18rem; + --shell-bar-height: 2.625rem; + --metrics-height: 3.5rem; + --statusbar-height: 1.625rem; + --virtual-footer-height: 2.125rem; + --table-header-height: 2rem; + --table-row-height: 2rem; + --shell-inline-padding: 0.75rem; + --table-cell-inline-padding: 0.6rem; + --control-radius: 1px; +} + +* { + box-sizing: border-box; +} + +html, +body { + min-width: 320px; + min-height: 100%; + margin: 0; + background: var(--background); +} + +body { + color: var(--text); +} + +button, +select, +input { + font: inherit; +} + +button, +select { + color: var(--text); + background: var(--panel-raised); + border: 1px solid var(--control-border); + border-radius: var(--control-radius); +} + +button { + padding: 0.5rem 0.65rem; + cursor: pointer; + font-size: 0.72rem; + font-weight: 650; + letter-spacing: 0.025em; +} + +button:hover { + background: var(--control-hover); + border-color: var(--border-strong); +} + +button:focus-visible, +select:focus-visible, +input:focus-visible { + outline: 1px solid var(--blue); + outline-offset: 1px; +} + +select { + width: 100%; + padding: 0.45rem 0.55rem; + font-size: 0.75rem; +} + +.trading-terminal { + --active-sidebar-width: var(--sidebar-width); + display: grid; + width: 100%; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--background); + grid-template-areas: + 'header sidebar' + 'content sidebar' + 'footer sidebar'; + grid-template-columns: minmax(0, 1fr) var(--active-sidebar-width); + grid-template-rows: auto minmax(0, 1fr) auto; + transition: grid-template-columns 160ms ease; +} + +.trading-terminal.is-sidebar-collapsed { + --active-sidebar-width: 0px; +} + +.shell-header { + display: flex; + min-width: 0; + flex-direction: column; + grid-area: header; +} + +.app-bar { + display: flex; + min-height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + background: var(--surface-bar); + border-bottom: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.045em; +} + +.brand, +.header-actions, +.feed-status { + display: flex; + align-items: center; +} + +.brand { + gap: 0.65rem; + color: var(--text-strong); +} + +.header-actions { + gap: 0.75rem; +} + +.sidebar-toggle { + display: grid; + width: 1.9rem; + height: 1.9rem; + padding: 0; + place-items: center; + color: var(--muted); + background: transparent; + border-color: transparent; +} + +.sidebar-toggle:hover { + color: var(--text-strong); + background: var(--surface-control); + border-color: var(--border); +} + +.sidebar-toggle svg { + width: 1rem; + height: 1rem; + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-width: 1.4; +} + +.feed-status { + gap: 0.4rem; + color: var(--muted); +} + +.feed-status.is-running { + color: var(--green); +} + +.status-dot { + width: 6px; + height: 6px; + background: var(--muted); + border-radius: 50%; +} + +.feed-status.is-running .status-dot { + background: var(--green); +} + +.development-warning { + flex: 0 0 auto; + padding: 0.3rem 0.75rem; + color: var(--amber); + background: color-mix(in srgb, var(--amber) 8%, var(--background)); + border-bottom: 1px solid color-mix(in srgb, var(--amber) 24%, var(--border)); + font-family: var(--font-mono); + font-size: 0.62rem; + letter-spacing: 0.04em; +} + +.market-panel { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--panel); + grid-area: content; +} + +.metrics-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + background: var(--border); + border-bottom: 1px solid var(--border); + gap: 1px; +} + +.metrics-strip h2 { + margin: 0; + padding: 0.65rem var(--shell-inline-padding) 0.45rem; + color: var(--muted); + background: var(--surface-table); + font-size: 0.58rem; + grid-column: 1 / -1; + letter-spacing: 0.075em; +} + +.metrics-strip article { + min-width: 0; + min-height: 4.25rem; + padding: 0.5rem var(--shell-inline-padding); + background: var(--surface-bar); +} + +.metrics-strip span, +.metrics-strip small { + display: block; + color: var(--muted); + font-size: 0.56rem; + letter-spacing: 0.045em; + line-height: 1.35; +} + +.metrics-strip strong { + display: block; + margin: 0.12rem 0 0.08rem; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.88rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.metrics-strip strong.metric-alert { + color: var(--amber); +} + +.table-scroll { + flex: 1; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.virtual-scroll-footer { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + min-height: var(--virtual-footer-height); + padding: 0 var(--shell-inline-padding); + color: var(--muted); + background: var(--surface-toolbar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.68rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +table { + min-width: 100%; + border-spacing: 0; + table-layout: fixed; + font-family: var(--font-ui); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.virtual-table { + display: grid; +} + +.virtual-table thead { + display: grid; +} + +.virtual-table thead tr, +.virtual-table-row { + display: flex; + width: 100%; +} + +.virtual-table th, +.virtual-table-row > td { + flex: 0 0 auto; +} + +.virtual-table-body { + position: relative; + display: grid; +} + +.virtual-table-row { + position: absolute; + left: 0; + height: var(--table-row-height); +} + +.virtual-table-row > td { + display: block; +} + +.trading-data-grid { + display: grid; +} + +.trading-data-grid thead, +.trading-data-grid tbody { + display: grid; +} + +.trading-data-grid tbody { + -webkit-user-select: none; + user-select: none; +} + +.trading-data-grid thead > tr, +.trading-data-grid tbody > tr { + display: flex; + width: 100%; +} + +.trading-data-grid tbody > tr { + min-height: var(--table-row-height); + content-visibility: auto; + contain-intrinsic-block-size: var(--table-row-height); +} + +.trading-data-grid th, +.trading-data-grid td { + flex: 0 0 auto; +} + +thead { + position: sticky; + z-index: 2; + top: 0; +} + +th { + height: var(--table-header-height); + padding: 0 var(--table-cell-inline-padding); + color: var(--muted); + background: var(--surface-header); + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.005em; + text-align: left; + white-space: nowrap; +} + +th.column-group-header { + color: #666b72; + font-size: 0.72rem; + font-weight: 500; + text-align: center; +} + +thead tr:first-child th.column-group-header { + color: #777d85; + font-size: 0.76rem; +} + +th.numeric-header { + text-align: right; +} + +td { + height: var(--table-row-height); + padding: 0 var(--table-cell-inline-padding); + overflow: hidden; + color: #eef1f5; + border-right: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border-soft); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +th:last-child, +td:last-child { + border-right: 0; +} + +td[data-column-id='market'], +td[data-column-id='name'], +td[data-column-id='symbol'] { + text-align: left; +} + +td[data-column-id='market'] { + color: var(--blue); + font-weight: 550; +} + +td[data-column-id='name'] { + color: var(--text-strong); +} + +td[data-column-id='symbol'] { + color: #d8dde4; + font-weight: 600; +} + +tbody tr:nth-child(even) { + background: var(--row-alt); +} + +tbody tr:hover { + background: var(--panel-hover); +} + +tbody tr[data-symbol-selected='true'] { + background: var(--row-selected); + box-shadow: inset 2px 0 0 var(--blue); +} + +.table-scroll:focus { + outline: none; +} + +th { + position: relative; +} + +.leaf-header-content { + display: flex; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + height: 100%; + align-items: stretch; + margin-inline: calc(-1 * var(--table-cell-inline-padding)); +} + +.column-drag-handle, +.sort-header-button { + min-width: 0; + height: 100%; + padding: 0; + color: inherit; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; +} + +.column-drag-handle { + flex: 0 0 1.05rem; + color: var(--muted); + cursor: grab; + opacity: 0.35; + font-size: 0.56rem; + letter-spacing: -0.32rem; + text-align: center; +} + +.column-drag-handle:active { + cursor: grabbing; +} + +th:hover .column-drag-handle, +.column-drag-handle:focus-visible { + opacity: 0.9; +} + +.column-drag-handle:hover, +.sort-header-button:hover { + background: rgb(255 255 255 / 4%); + border-color: transparent; +} + +th.is-column-dragging { + opacity: 0.45; +} + +th.is-column-drop-target { + background: rgb(50 139 255 / 16%); + box-shadow: inset 4px 0 0 var(--blue); +} + +th.is-column-drop-target::before { + position: absolute; + z-index: 6; + top: -1px; + bottom: -1px; + left: -2px; + width: 4px; + background: #62a9ff; + box-shadow: + 0 0 0 1px rgb(0 0 0 / 55%), + 0 0 12px rgb(70 151 255 / 90%); + content: ''; + pointer-events: none; +} + +.sort-header-button { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: flex-start; + gap: 0.3rem; + padding-inline: 0.25rem var(--table-cell-inline-padding); + cursor: default; + text-align: inherit; +} + +.numeric-header .sort-header-button { + justify-content: flex-end; +} + +.sort-header-button.is-sortable { + cursor: pointer; +} + +.sort-header-button:disabled { + opacity: 1; +} + +.header-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sort-indicator { + flex: 0 0 auto; + color: #66717e; + font-size: 0.72rem; +} + +.sort-indicator.is-active { + color: var(--blue-soft); +} + +.column-resize-handle { + position: absolute; + z-index: 3; + top: 0; + right: -3px; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; + user-select: none; +} + +.column-resize-handle::after { + position: absolute; + top: 20%; + right: 3px; + width: 1px; + height: 60%; + background: var(--border-strong); + content: ''; + opacity: 0; +} + +th:hover > .column-resize-handle::after, +.column-resize-handle.is-resizing::after { + background: var(--blue); + opacity: 1; +} + +tbody tr[aria-selected='true'] { + background: rgb(76 145 228 / 13%); +} + +td[aria-selected='true'] { + position: relative; + background: rgb(70 139 222 / 12%); +} + +td[data-cell-focused='true'] { + background: rgb(70 139 222 / 21%); +} + +td[aria-selected='true']::after { + position: absolute; + z-index: 1; + inset: 0; + border: 1px solid transparent; + content: ''; + pointer-events: none; +} + +td[data-selection-top='true']::after { + border-top-color: var(--blue); +} + +td[data-selection-right='true']::after { + border-right-color: var(--blue); +} + +td[data-selection-bottom='true']::after { + border-bottom-color: var(--blue); +} + +td[data-selection-left='true']::after { + border-left-color: var(--blue); +} + +.price-button { + min-width: 4.5rem; + padding: 0; + background: transparent; + border: 0; + border-radius: 0; + font-family: inherit; + font-size: inherit; + font-variant-numeric: tabular-nums; + font-weight: 600; + text-align: right; +} + +.price-button:hover { + background: transparent; + text-decoration: underline; + text-underline-offset: 2px; +} + +.move-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.move-cell.quote-up, +.percent-change-cell.quote-up { + background: color-mix(in srgb, var(--green) 7%, transparent); +} + +.move-cell.quote-down, +.percent-change-cell.quote-down { + background: color-mix(in srgb, var(--red) 7%, transparent); +} + +.percent-change-cell { + display: block; + width: calc(100% + 2 * var(--table-cell-inline-padding)); + margin-inline: calc(-1 * var(--table-cell-inline-padding)); + padding-inline: var(--table-cell-inline-padding); + line-height: var(--table-row-height); + text-align: right; +} + +.spread-cell { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 0.35rem; + color: var(--text); +} + +.spread-cell small { + min-width: 2.8rem; + color: var(--muted); + font-size: 0.55rem; +} + +.spread-cell.spread-wide, +.spread-cell.spread-wide small { + color: var(--amber); +} + +.depth-cell { + position: relative; + display: flex; + height: 17px; + overflow: hidden; + background: var(--surface-control); +} + +.depth-bid, +.depth-ask { + height: 100%; + opacity: 0.78; +} + +.depth-bid { + background: var(--depth-bid); + border-right: 1px solid var(--panel); +} + +.depth-ask { + background: var(--depth-ask); +} + +.depth-values { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.28rem; + color: var(--text-strong); + font-size: 0.57rem; + text-shadow: 0 1px var(--background); +} + +.quote-age { + color: var(--muted); +} + +.quote-age-warm { + color: var(--amber); +} + +.quote-age-stale { + color: var(--red); +} + +.quote-up { + color: var(--green); +} + +.quote-down { + color: var(--red); +} + +.sparkline { + display: block; + width: 100%; + height: 1.35rem; + margin-left: auto; + color: var(--blue-soft); + overflow: visible; +} + +.sparkline.quote-up { + color: var(--green); +} + +.sparkline.quote-down { + color: var(--red); +} + +.sparkline polyline { + fill: none; + stroke: currentColor; + stroke-linecap: square; + stroke-linejoin: miter; + stroke-width: 1.25; + vector-effect: non-scaling-stroke; +} + +.market-statusbar { + display: flex; + flex: 0 0 var(--statusbar-height); + min-height: var(--statusbar-height); + gap: 1.1rem; + align-items: center; + padding: 0 0.65rem; + color: var(--muted); + background: var(--surface-bar); + border-top: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 0.56rem; + grid-area: footer; + letter-spacing: 0.035em; +} + +.market-statusbar strong { + margin-left: 0.25rem; + color: var(--text); + font-weight: 500; +} + +.sidebar-slot { + width: var(--sidebar-width); + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--surface-table); + border-left: 1px solid var(--border); + grid-area: sidebar; + transition: + opacity 120ms ease, + transform 160ms ease, + visibility 160ms; +} + +.is-sidebar-collapsed .sidebar-slot { + visibility: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(100%); +} + +.configurator { + width: 100%; + height: 100%; + min-height: 0; + overflow: auto; + background: var(--surface-table); +} + +.configurator > header { + display: flex; + height: var(--shell-bar-height); + align-items: center; + justify-content: space-between; + padding: 0 var(--shell-inline-padding); + color: var(--text-strong); + background: var(--surface-toolbar); + border-bottom: 1px solid var(--border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.055em; +} + +.configurator > header small { + color: var(--muted); + font-size: 0.54rem; + font-weight: 500; +} + +.config-section { + display: grid; + gap: 0.7rem; + padding: 0.8rem var(--shell-inline-padding); + border-bottom: 1px solid var(--border); +} + +.config-section h2 { + margin: 0; + color: var(--muted); + font-size: 0.58rem; + letter-spacing: 0.075em; +} + +.primary-action { + width: 100%; + color: var(--text-inverse); + background: var(--blue); + border-color: var(--blue-soft); +} + +.primary-action:hover { + background: var(--blue-soft); + border-color: var(--text-strong); +} + +.field { + display: grid; + gap: 0.35rem; + color: var(--text); + font-size: 0.66rem; +} + +.rate-field > span { + display: flex; + justify-content: space-between; +} + +.rate-field strong { + color: var(--text); + font-family: var(--font-mono); + font-weight: 500; +} + +.field small { + color: var(--muted); + font-family: var(--font-mono); + font-size: 0.55rem; +} + +input[type='range'] { + width: 100%; + margin: 0; + accent-color: var(--blue); +} + +.toggle-field { + display: flex; + gap: 0.5rem; + align-items: flex-start; + color: var(--text); + font-size: 0.66rem; + line-height: 1.3; +} + +.toggle-field input { + margin: 0.12rem 0 0; + accent-color: var(--blue); +} + +.toggle-field small { + display: block; + margin-top: 0.14rem; + color: var(--muted); + font-size: 0.57rem; +} + +.action-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; +} + +.diagnostics dl, +.selected-instrument dl { + display: grid; + gap: 0; + margin: 0; +} + +.diagnostics dl > div, +.selected-instrument dl > div { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + border-bottom: 1px solid var(--border-soft); +} + +.diagnostics dl > div:last-child, +.selected-instrument dl > div:last-child { + border-bottom: 0; +} + +.diagnostics dl > div:has(dd[data-testid$='-breakdown']) { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-content: center; + padding-block: 0.28rem; +} + +.diagnostics dd[data-testid$='-breakdown'] { + width: 100%; + overflow-x: auto; + padding-block: 0.12rem; + white-space: nowrap; + scrollbar-width: thin; +} + +dt { + color: var(--muted); + font-size: 0.61rem; +} + +dd { + margin: 0; + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.62rem; +} + +.selection { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.selection div { + display: grid; + gap: 0.18rem; +} + +.selection strong { + color: var(--text-strong); + font-family: var(--font-mono); + font-size: 0.85rem; +} + +.selection span, +.selection small, +.selected-instrument p { + color: var(--muted); + font-size: 0.61rem; +} + +.selected-instrument p { + margin: 0; + line-height: 1.45; +} + +@media (max-width: 980px) { + :root { + --sidebar-width: 15.625rem; + } +} + +@media (max-width: 720px) { + .trading-terminal { + --active-sidebar-width: 0px; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + + .market-panel { + min-height: 0; + } + + .table-scroll { + min-height: 0; + } + + .sidebar-slot { + position: fixed; + z-index: 20; + top: 0; + right: 0; + bottom: 0; + width: min(var(--sidebar-width), calc(100vw - 2.75rem)); + box-shadow: -18px 0 42px rgb(0 0 0 / 48%); + } + + .is-sidebar-collapsed .sidebar-slot { + box-shadow: none; + } +} diff --git a/examples/vue/realtime-trading/src/main.ts b/examples/vue/realtime-trading/src/main.ts new file mode 100644 index 0000000000..196e4d4b2c --- /dev/null +++ b/examples/vue/realtime-trading/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import { App } from './App' +import './index.css' + +createApp(App).mount('#root') diff --git a/examples/vue/realtime-trading/src/shell/TradingShell.tsx b/examples/vue/realtime-trading/src/shell/TradingShell.tsx new file mode 100644 index 0000000000..26fd235f2d --- /dev/null +++ b/examples/vue/realtime-trading/src/shell/TradingShell.tsx @@ -0,0 +1,612 @@ +import { computed, defineComponent, ref } from 'vue' +import { + feedSampleRateAt, + feedSampleRateIndex, + feedSampleRateOptions, +} from '../feed/feed-sample-rates' +import { + FORCED_VIRTUALIZATION_ROW_COUNT, + resolveVirtualScrollMode, +} from '../table/trading-row-virtualizer' +import { + useMarketFeedController, + useTradingShellController, +} from './trading-shell-context' +import { configuratorOptions } from './configurator-options' +import type { FeedMetrics } from '../benchmark/benchmark-monitor' + +const integerFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 0, +}) +const rateFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const TradingShell = defineComponent({ + name: 'TradingShell', + setup(_, { slots }) { + const sidebarOpen = ref(true) + return () => ( +
+
+ { + sidebarOpen.value = !sidebarOpen.value + }} + /> + {import.meta.env.DEV && ( + + )} +
+
+ {slots.default?.()} +
+ + +
+ ) + }, +}) + +const AppHeader = defineComponent({ + name: 'AppHeader', + props: { + sidebarOpen: { type: Boolean, required: true }, + onSidebarToggle: { type: Function, required: true }, + }, + setup(props) { + const feed = useMarketFeedController() + return () => ( +
+
+ MARKET MONITOR +
+
+ + + +
+
+ ) + }, +}) + +const MetricsStrip = defineComponent({ + name: 'MetricsStrip', + setup() { + const controller = useTradingShellController() + return () => { + const metrics = controller.metrics.value + return ( +
+

LIVE HEALTH

+ + + +
+ THROUGHPUT + + {formatRate(metrics.rowUpdatesPerSecond)} rows/s + + + {metrics.stateApplicationsPerSecond.toFixed(1)} snapshots/s · rows + deduplicated per snapshot + +
+
+ ) + } + }, +}) + +const Metric = defineComponent({ + name: 'Metric', + props: { + label: { type: String, required: true }, + value: { type: String, required: true }, + detail: { type: String, required: true }, + testId: String, + }, + setup(props) { + return () => ( +
+ {props.label} + {props.value} + {props.detail} +
+ ) + }, +}) + +const MarketStatusbar = defineComponent({ + name: 'MarketStatusbar', + setup() { + const controller = useTradingShellController() + return () => { + const metrics = controller.metrics.value + return ( +
+ + MESSAGE SAMPLES{' '} + {formatInteger(metrics.lastBatchSize)} + + + CHANGED ROWS{' '} + {formatInteger(metrics.lastUpdateCount)} + + + HOSTS{' '} + {formatInteger(controller.mountedCells.value)} + + + COMPONENTS{' '} + {formatInteger(controller.liveComponents.value)} + +
+ ) + } + }, +}) + +const Configurator = defineComponent({ + name: 'Configurator', + setup() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const virtualScrollForced = computed( + () => feed.instrumentCount.value >= FORCED_VIRTUALIZATION_ROW_COUNT, + ) + const virtualScrollMode = computed(() => + resolveVirtualScrollMode( + controller.requestedVirtualScrollMode.value, + feed.instrumentCount.value, + ), + ) + + return () => ( + + ) + }, +}) + +const Diagnostics = defineComponent({ + name: 'Diagnostics', + setup() { + const controller = useTradingShellController() + return () => { + const metrics = controller.metrics.value + return ( +
+

DIAGNOSTICS

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ ) + } + }, +}) + +const Diagnostic = defineComponent({ + name: 'Diagnostic', + props: { + label: { type: String, required: true }, + value: { type: String, required: true }, + testId: String, + }, + setup(props) { + return () => ( +
+
{props.label}
+
{props.value}
+
+ ) + }, +}) + +const SelectedInstrument = defineComponent({ + name: 'SelectedInstrument', + setup() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const selectedQuote = computed(() => + feed.getQuoteBySymbol(feed.quotes.value, controller.selectedSymbol.value), + ) + return () => ( +
+

SELECTED INSTRUMENT

+ {selectedQuote.value ? ( + <> +
+
+ {selectedQuote.value.symbol} + {selectedQuote.value.company} +
+ {selectedQuote.value.venue} +
+
+
+
Last
+
{selectedQuote.value.price.toFixed(2)}
+
+
+
Bid / ask
+
+ {selectedQuote.value.bid.toFixed(2)} /{' '} + {selectedQuote.value.ask.toFixed(2)} +
+
+
+ + ) : ( +

+ Click or begin a cell selection in any row to inspect its + instrument. +

+ )} +
+ ) + }, +}) + +function optionNode(option: { + readonly label: string + readonly value: number | string +}) { + return ( + + ) +} + +function stringValue(event: Event): string { + return (event.target as HTMLInputElement | HTMLSelectElement).value +} + +function numberValue(event: Event): number { + return Number(stringValue(event)) +} + +function checkedValue(event: Event): boolean { + return (event.target as HTMLInputElement).checked +} + +function formatRate(value: number): string { + return rateFormatter.format(value) +} + +function formatInteger(value: number): string { + return integerFormatter.format(value) +} + +function formatMs(value: number): string { + return `${value.toFixed(2)} ms` +} + +function formatInvocationRates( + rates: FeedMetrics['cellRendererRates'], +): string { + const active = rates.filter((entry) => entry.callsPerSecond > 0) + return active.length === 0 + ? '—' + : active + .map((entry) => `${entry.name} ${formatRate(entry.callsPerSecond)}`) + .join(' · ') +} diff --git a/examples/vue/realtime-trading/src/shell/configurator-options.ts b/examples/vue/realtime-trading/src/shell/configurator-options.ts new file mode 100644 index 0000000000..49a9566fe7 --- /dev/null +++ b/examples/vue/realtime-trading/src/shell/configurator-options.ts @@ -0,0 +1,55 @@ +export interface ConfiguratorOption { + readonly label: string + readonly value: TValue +} + +const instrumentCountOptions = [ + { label: '50', value: 50 }, + { label: '100', value: 100 }, + { label: '150', value: 150 }, + { label: '250', value: 250 }, + { label: '350', value: 350 }, + { label: '500', value: 500 }, + { label: '750', value: 750 }, + { label: '1,000', value: 1_000 }, + { label: '1,500', value: 1_500 }, + { label: '2,500', value: 2_500 }, + { label: '5,000', value: 5_000 }, + { label: '1,000,000', value: 1_000_000 }, + { label: '10,000,000', value: 10_000_000 }, +] as const satisfies ReadonlyArray> + +const workerDeliveryOptions = [ + { label: '8 ms · 125 msg/s', value: 8 }, + { label: '16 ms · 62.5 msg/s', value: 16 }, + { label: '20 ms · 50 msg/s', value: 20 }, + { label: '33 ms · 30 msg/s', value: 33 }, + { label: '50 ms · 20 msg/s', value: 50 }, + { label: '100 ms · 10 msg/s', value: 100 }, + { label: '250 ms · 4 msg/s', value: 250 }, + { label: '500 ms · 2 msg/s', value: 500 }, + { label: '1,000 ms · 1 msg/s', value: 1_000 }, +] as const satisfies ReadonlyArray> + +const rowRenderingOptions = [ + { label: 'Full DOM · render every row', value: 'none' }, + { label: 'TanStack Virtual · visible rows only', value: 'tanstack' }, +] as const satisfies ReadonlyArray> + +const intradaySamplingOptions = [ + { label: '16 ms · fastest', value: 16 }, + { label: '33 ms · very fast', value: 33 }, + { label: '50 ms · fast', value: 50 }, + { label: '100 ms', value: 100 }, + { label: '250 ms', value: 250 }, + { label: '500 ms', value: 500 }, + { label: '1,000 ms', value: 1_000 }, + { label: '2,000 ms', value: 2_000 }, +] as const satisfies ReadonlyArray> + +export const configuratorOptions = { + instrumentCounts: instrumentCountOptions, + workerDeliveryIntervals: workerDeliveryOptions, + rowRenderingModes: rowRenderingOptions, + intradaySamplingIntervals: intradaySamplingOptions, +} as const diff --git a/examples/vue/realtime-trading/src/shell/trading-shell-context.ts b/examples/vue/realtime-trading/src/shell/trading-shell-context.ts new file mode 100644 index 0000000000..0a08f021bd --- /dev/null +++ b/examples/vue/realtime-trading/src/shell/trading-shell-context.ts @@ -0,0 +1,31 @@ +import { inject, provide } from 'vue' +import type { InjectionKey } from 'vue' +import type { MarketFeedController } from '../feed/market-feed-controller' +import type { TradingBenchmarkController } from '../benchmark/trading-benchmark-controller' + +interface TradingControllers { + benchmark: TradingBenchmarkController + feed: MarketFeedController +} + +const tradingControllersKey: InjectionKey = Symbol( + 'trading-controllers', +) + +export function provideTradingControllers( + benchmark: TradingBenchmarkController, +): void { + provide(tradingControllersKey, { benchmark, feed: benchmark.feed }) +} + +export function useTradingShellController(): TradingBenchmarkController { + const controllers = inject(tradingControllersKey) + if (!controllers) throw new Error('Missing trading controllers') + return controllers.benchmark +} + +export function useMarketFeedController(): MarketFeedController { + const controllers = inject(tradingControllersKey) + if (!controllers) throw new Error('Missing trading controllers') + return controllers.feed +} diff --git a/examples/vue/realtime-trading/src/table/table-config/quote-cells.tsx b/examples/vue/realtime-trading/src/table/table-config/quote-cells.tsx new file mode 100644 index 0000000000..810a7386bb --- /dev/null +++ b/examples/vue/realtime-trading/src/table/table-config/quote-cells.tsx @@ -0,0 +1,198 @@ +import { defineComponent, onMounted, onUnmounted } from 'vue' +import type { PropType } from 'vue' + +export const quoteCellLifecycle = { + created: 0, + destroyed: 0, +} + +export const quoteCellRendererNames = [ + 'Market', + 'Name', + 'Symbol', + 'Last', + 'Change', + 'ChangePercent', + 'Bid', + 'BidVolume', + 'Ask', + 'AskVolume', + 'Open', + 'High', + 'Low', + 'Intraday', +] as const + +export const quoteComponentNames = [ + 'PriceCell', + 'StableMoveCell', + 'UpMoveCell', + 'DownMoveCell', + 'PercentChangeCell', + 'SparklineCell', +] as const + +export type QuoteCellRendererName = (typeof quoteCellRendererNames)[number] +export type QuoteComponentName = (typeof quoteComponentNames)[number] + +const createCounterMap = ( + names: ReadonlyArray, +): Record => + Object.fromEntries(names.map((name) => [name, 0])) as Record + +export const quoteRenderDiagnostics = { + cellRendererCalls: 0, + componentRenderCalls: 0, + cellRendererCallsByName: createCounterMap(quoteCellRendererNames), + componentRenderCallsByName: createCounterMap(quoteComponentNames), +} + +export function recordCellRender(name: QuoteCellRendererName, value: T): T { + quoteRenderDiagnostics.cellRendererCalls++ + quoteRenderDiagnostics.cellRendererCallsByName[name]++ + return value +} + +function useLifecycleCounter(componentName: QuoteComponentName): () => void { + onMounted(() => { + quoteCellLifecycle.created++ + }) + onUnmounted(() => { + quoteCellLifecycle.destroyed++ + }) + return () => { + quoteRenderDiagnostics.componentRenderCalls++ + quoteRenderDiagnostics.componentRenderCallsByName[componentName]++ + } +} + +export const PriceCell = defineComponent({ + name: 'PriceCell', + props: { + price: { type: Number, required: true }, + move: { type: Number, required: true }, + onSelect: { + type: Function as PropType<() => void>, + required: true, + }, + }, + setup(props) { + const recordRender = useLifecycleCounter('PriceCell') + return () => { + recordRender() + return ( + + ) + } + }, +}) + +function createMoveCell( + name: 'StableMoveCell' | 'UpMoveCell' | 'DownMoveCell', + fixedDirection?: 'up' | 'down', +) { + return defineComponent({ + name, + props: { move: { type: Number, required: true } }, + setup(props) { + const recordRender = useLifecycleCounter(name) + return () => { + recordRender() + const direction = fixedDirection ?? (props.move >= 0 ? 'up' : 'down') + const indicator = + fixedDirection === 'up' ? '▲ ' : fixedDirection === 'down' ? '▼ ' : '' + return ( + + {indicator} + {formatSigned(props.move)} + + ) + } + }, + }) +} + +export const StableMoveCell = createMoveCell('StableMoveCell') +export const UpMoveCell = createMoveCell('UpMoveCell', 'up') +export const DownMoveCell = createMoveCell('DownMoveCell', 'down') + +export const PercentChangeCell = defineComponent({ + name: 'PercentChangeCell', + props: { value: { type: Number, required: true } }, + setup(props) { + const recordRender = useLifecycleCounter('PercentChangeCell') + return () => { + recordRender() + return ( + = 0 ? 'quote-up' : 'quote-down', + ]} + > + {props.value >= 0 ? '+' : ''} + {props.value.toFixed(2)}% + + ) + } + }, +}) + +export const SparklineCell = defineComponent({ + name: 'SparklineCell', + props: { + values: { + type: Array as PropType>, + required: true, + }, + }, + setup(props) { + const recordRender = useLifecycleCounter('SparklineCell') + return () => { + recordRender() + const rising = (props.values.at(-1) ?? 0) >= (props.values[0] ?? 0) + const { min, max } = findRange(props.values) + const range = max - min || 1 + const denominator = Math.max(1, props.values.length - 1) + const points = props.values + .map((value, index) => { + const x = (index / denominator) * 100 + const y = 22 - ((value - min) / range) * 20 + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + return ( + + + + ) + } + }, +}) + +function formatSigned(value: number): string { + return `${value >= 0 ? '+' : ''}${value.toFixed(2)}` +} + +function findRange(values: ReadonlyArray): { + min: number + max: number +} { + const first = values[0] ?? 0 + return values.reduce( + (range, value) => ({ + min: Math.min(range.min, value), + max: Math.max(range.max, value), + }), + { min: first, max: first }, + ) +} diff --git a/examples/vue/realtime-trading/src/table/table-config/trading-columns.tsx b/examples/vue/realtime-trading/src/table/table-config/trading-columns.tsx new file mode 100644 index 0000000000..233a0eab37 --- /dev/null +++ b/examples/vue/realtime-trading/src/table/table-config/trading-columns.tsx @@ -0,0 +1,279 @@ +import { defineComponent } from 'vue' +import { useTradingShellController } from '../../shell/trading-shell-context' +import { + DownMoveCell, + PercentChangeCell, + PriceCell, + SparklineCell, + StableMoveCell, + UpMoveCell, + recordCellRender, +} from './quote-cells' +import type { PropType, VNodeChild } from 'vue' +import type { MarketQuote } from '../../feed/market-data' + +export type RendererMode = 'stable' | 'swap' +export interface CoreTableState { + sorting: Array<{ id: string; desc: boolean }> + columnFilters: Array<{ id: string; value: unknown }> +} + +interface TradingCellContext { + row: { original: MarketQuote } +} + +export interface TradingColumnDefinition { + id: string + header: string + size?: number + columns?: Array + accessorFn?: (row: MarketQuote) => unknown + enableSorting?: boolean + filterFn?: 'includesString' + sortFn?: 'basic' + cell?: (context: TradingCellContext) => VNodeChild +} + +const compactFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, +}) + +export const tradingColumns: Array = [ + { + id: 'instrument', + header: 'Instrument', + columns: [ + { + id: 'market', + header: 'Market', + size: 72, + accessorFn: (row) => row.venue, + cell: ({ row }) => recordCellRender('Market', row.original.venue), + }, + { + id: 'name', + header: 'Name', + size: 180, + accessorFn: (row) => row.company, + cell: ({ row }) => recordCellRender('Name', row.original.company), + }, + { + id: 'symbol', + header: 'Symbol', + size: 92, + accessorFn: (row) => row.symbol, + filterFn: 'includesString', + cell: ({ row }) => recordCellRender('Symbol', row.original.symbol), + }, + ], + }, + { + id: 'priceAndChange', + header: 'Price & Change', + columns: [ + { + id: 'price', + header: 'Price', + size: 96, + accessorFn: (row) => row.price, + sortFn: 'basic', + cell: ({ row }) => + recordCellRender('Last', ), + }, + { + id: 'change', + header: 'Chg', + size: 94, + accessorFn: (row) => getDayChange(row), + cell: ({ row }) => + recordCellRender('Change', ), + }, + { + id: 'changePercent', + header: 'Chg%', + size: 90, + accessorFn: (row) => getDayChangePercent(row), + cell: ({ row }) => + recordCellRender( + 'ChangePercent', + , + ), + }, + ], + }, + { + id: 'orderBook', + header: 'Order Book', + columns: [ + { + id: 'bid', + header: 'Bid', + size: 90, + accessorFn: (row) => row.bid, + cell: ({ row }) => recordCellRender('Bid', row.original.bid.toFixed(2)), + }, + { + id: 'bidSize', + header: 'Bid Vol', + size: 100, + accessorFn: (row) => row.bidSize, + cell: ({ row }) => + recordCellRender( + 'BidVolume', + compactFormatter.format(row.original.bidSize), + ), + }, + { + id: 'ask', + header: 'Ask', + size: 90, + accessorFn: (row) => row.ask, + cell: ({ row }) => recordCellRender('Ask', row.original.ask.toFixed(2)), + }, + { + id: 'askSize', + header: 'Ask Vol', + size: 100, + accessorFn: (row) => row.askSize, + cell: ({ row }) => + recordCellRender( + 'AskVolume', + compactFormatter.format(row.original.askSize), + ), + }, + ], + }, + { + id: 'session', + header: 'Session', + columns: [ + { + id: 'open', + header: 'Open', + size: 90, + accessorFn: (row) => row.open, + cell: ({ row }) => + recordCellRender('Open', row.original.open.toFixed(2)), + }, + { + id: 'high', + header: 'High', + size: 90, + accessorFn: (row) => row.high, + cell: ({ row }) => + recordCellRender('High', row.original.high.toFixed(2)), + }, + { + id: 'low', + header: 'Low', + size: 90, + accessorFn: (row) => row.low, + cell: ({ row }) => recordCellRender('Low', row.original.low.toFixed(2)), + }, + ], + }, + { + id: 'chart', + header: 'Chart', + columns: [ + { + id: 'history', + header: 'Intraday', + size: 150, + enableSorting: false, + cell: ({ row }) => + recordCellRender( + 'Intraday', + , + ), + }, + ], + }, +] + +export const rowModelDiagnostics = { + hasMeasurement: false, + calls: 0, + totalDurationMs: 0, + maxDurationMs: 0, + lastRowCount: 0, +} + +export const TRADING_COLUMN_COUNT = 14 + +export function readMeasuredRows(readRows: () => Array): Array { + const start = performance.now() + const rows = readRows() + const end = performance.now() + const duration = end - start + rowModelDiagnostics.calls++ + rowModelDiagnostics.hasMeasurement = true + rowModelDiagnostics.totalDurationMs += duration + rowModelDiagnostics.maxDurationMs = Math.max( + rowModelDiagnostics.maxDurationMs, + duration, + ) + rowModelDiagnostics.lastRowCount = rows.length + if (rowModelDiagnostics.calls % 20 === 0) { + try { + performance.measure('tanstack-row-model', { + start, + end, + detail: { rowCount: rows.length }, + }) + if (rowModelDiagnostics.calls % 20_000 === 0) { + performance.clearMeasures('tanstack-row-model') + } + } catch { + // User Timing Level 3 options are not implemented in every browser. + } + } + return rows +} + +const LastPriceCell = defineComponent({ + name: 'LastPriceCell', + props: { + quote: { type: Object as PropType, required: true }, + }, + setup(props) { + const { selectSymbol } = useTradingShellController().actions + return () => ( + selectSymbol(props.quote.symbol)} + /> + ) + }, +}) + +const DayChangeCell = defineComponent({ + name: 'DayChangeCell', + props: { + quote: { type: Object as PropType, required: true }, + }, + setup(props) { + const mode = useTradingShellController().rendererMode + return () => { + const change = getDayChange(props.quote) + if (mode.value === 'stable') return + return change >= 0 ? ( + + ) : ( + + ) + } + }, +}) + +export function getDayChange(quote: MarketQuote): number { + return quote.price - quote.previousClose +} + +export function getDayChangePercent(quote: MarketQuote): number { + return quote.previousClose === 0 + ? 0 + : (getDayChange(quote) / quote.previousClose) * 100 +} diff --git a/examples/vue/realtime-trading/src/table/table-interactions.ts b/examples/vue/realtime-trading/src/table/table-interactions.ts new file mode 100644 index 0000000000..99649a40bd --- /dev/null +++ b/examples/vue/realtime-trading/src/table/table-interactions.ts @@ -0,0 +1,187 @@ +export type CellDirection = 'up' | 'down' | 'left' | 'right' + +interface RowSelectionTable { + resetRowSelection: (defaultState?: boolean) => void +} + +interface SelectableRow { + getIsSelected: () => boolean + getToggleSelectedHandler: (options?: { + selectChildren?: boolean + }) => (event: unknown) => void +} + +interface SelectableGridCell { + row: SelectableGridRow + getSelectionStartHandler: ( + contextDocument?: Document, + ) => (event: unknown) => void + getSelectionExtendHandler: () => (event: unknown) => void +} + +interface SelectableGridRow extends SelectableRow { + original: { symbol: string } + getAllCellsByColumnId: () => Record +} + +interface TradingGridTable extends RowSelectionTable { + getRowModel: () => { + rowsById: Record + } +} + +interface SelectionCellTarget { + element: HTMLTableCellElement + cell: SelectableGridCell +} + +interface CellNavigationTable { + extendCellSelection: (direction: CellDirection) => void + moveCellSelection: (direction: CellDirection) => void + resetCellSelection: (defaultState?: boolean) => void + selectAllCells: () => void +} + +const keyDirections: Partial> = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', +} + +export function reorderColumnIds( + columnIds: Array, + sourceId: string, + targetId: string, +): Array { + if (sourceId === targetId || !columnIds.includes(sourceId)) return columnIds + + const withoutSource = columnIds.filter((id) => id !== sourceId) + const targetIndex = withoutSource.indexOf(targetId) + if (targetIndex < 0) return columnIds + + return [ + ...withoutSource.slice(0, targetIndex), + sourceId, + ...withoutSource.slice(targetIndex), + ] +} + +export function selectRowFromPointer( + table: RowSelectionTable, + row: SelectableRow, + event: MouseEvent, +): void { + const additive = event.ctrlKey || event.metaKey + const checked = additive ? !row.getIsSelected() : true + + if (!event.shiftKey && !additive) table.resetRowSelection(true) + + row.getToggleSelectedHandler({ selectChildren: false })({ + target: { checked }, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }) +} + +export class TradingGridPointerController { + #lastPointerCell: HTMLTableCellElement | null = null + + handleMouseDown( + table: TradingGridTable, + event: MouseEvent, + selectSymbol: (symbol: string) => void, + ): void { + if (event.button !== 0) return + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + + event.preventDefault() + this.#lastPointerCell = target.element + selectSymbol(target.cell.row.original.symbol) + target.cell.getSelectionStartHandler(target.element.ownerDocument)(event) + } + + handlePointerOver(table: TradingGridTable, event: MouseEvent): void { + if ((event.buttons & 1) === 0) { + this.resetPointerCell() + return + } + + const target = this.#findCellTarget(table, event.composedPath()) + if (!target || target.element === this.#lastPointerCell) return + + this.#lastPointerCell = target.element + target.cell.getSelectionExtendHandler()(event) + } + + handleClick(table: TradingGridTable, event: MouseEvent): void { + const target = this.#findCellTarget(table, event.composedPath()) + if (!target) return + selectRowFromPointer(table, target.cell.row, event) + } + + resetPointerCell(): void { + this.#lastPointerCell = null + } + + #findCellTarget( + table: TradingGridTable, + path: Array, + ): SelectionCellTarget | null { + for (const target of path) { + if (!(target instanceof HTMLTableCellElement)) continue + + const columnId = target.dataset['columnId'] + const rowId = + target.closest('tr[data-row-id]')?.dataset['rowId'] + if (!columnId || !rowId) return null + + const row = table.getRowModel().rowsById[rowId] + const cell = row.getAllCellsByColumnId()[columnId] + return { element: target, cell } + } + + return null + } +} + +export function handleCellNavigation( + table: CellNavigationTable, + event: KeyboardEvent, +): void { + if (event.key === 'Escape') { + event.preventDefault() + table.resetCellSelection(true) + return + } + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault() + table.selectAllCells() + return + } + + const direction = keyDirections[event.key] + if (!direction) return + + event.preventDefault() + if (event.shiftKey) table.extendCellSelection(direction) + else table.moveCellSelection(direction) +} + +export function sortIndicator(direction: false | 'asc' | 'desc'): string { + if (direction === 'asc') return '↑' + if (direction === 'desc') return '↓' + return '↕' +} + +export function sortAriaValue( + direction: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { + if (direction === 'asc') return 'ascending' + if (direction === 'desc') return 'descending' + return 'none' +} diff --git a/examples/vue/realtime-trading/src/table/trading-row-virtualizer.ts b/examples/vue/realtime-trading/src/table/trading-row-virtualizer.ts new file mode 100644 index 0000000000..bc4b34b528 --- /dev/null +++ b/examples/vue/realtime-trading/src/table/trading-row-virtualizer.ts @@ -0,0 +1,18 @@ +export const TRADING_ROW_HEIGHT = 32 +export const TRADING_ROW_OVERSCAN = 10 +export const DEFAULT_VIRTUALIZATION_ROW_COUNT = 200 +export const FORCED_VIRTUALIZATION_ROW_COUNT = 1_500 + +export type VirtualScrollMode = 'tanstack' | 'none' +export type VirtualScrollPreference = VirtualScrollMode | 'auto' + +export function resolveVirtualScrollMode( + requestedMode: VirtualScrollPreference, + instrumentCount: number, +): VirtualScrollMode { + if (instrumentCount >= FORCED_VIRTUALIZATION_ROW_COUNT) return 'tanstack' + if (requestedMode !== 'auto') return requestedMode + return instrumentCount >= DEFAULT_VIRTUALIZATION_ROW_COUNT + ? 'tanstack' + : 'none' +} diff --git a/examples/vue/realtime-trading/src/table/trading-table.tsx b/examples/vue/realtime-trading/src/table/trading-table.tsx new file mode 100644 index 0000000000..7bb46fff0d --- /dev/null +++ b/examples/vue/realtime-trading/src/table/trading-table.tsx @@ -0,0 +1,505 @@ +import { + computed, + defineComponent, + nextTick, + onBeforeUnmount, + onMounted, + onUpdated, + ref, + watch, + watchEffect, +} from 'vue' +import { + FlexRender, + createFilteredRowModel, + createSortedRowModel, + filterFn_includesString, + sortFn_basic, + stockFeatures, + tableFeatures, + useTable, +} from '@tanstack/vue-table' +import { useVirtualizer } from '@tanstack/vue-virtual' +import { useTableBenchmark } from '../benchmark/use-table-benchmark' +import { + useMarketFeedController, + useTradingShellController, +} from '../shell/trading-shell-context' +import { + TRADING_COLUMN_COUNT, + readMeasuredRows, + rowModelDiagnostics, + tradingColumns, +} from './table-config/trading-columns' +import { + TradingGridPointerController, + handleCellNavigation, + reorderColumnIds, + sortAriaValue, + sortIndicator, +} from './table-interactions' +import { + TRADING_ROW_HEIGHT, + TRADING_ROW_OVERSCAN, + resolveVirtualScrollMode, +} from './trading-row-virtualizer' +import type { PropType } from 'vue' +import type { VirtualItem } from '@tanstack/vue-virtual' +import type { MarketQuote } from '../feed/market-data' + +export { TRADING_COLUMN_COUNT, rowModelDiagnostics } +export type { + CoreTableState, + RendererMode, +} from './table-config/trading-columns' +export type { VirtualScrollMode } from './trading-row-virtualizer' + +const features = tableFeatures({ + ...stockFeatures, + filteredRowModel: createFilteredRowModel(), + sortedRowModel: createSortedRowModel(), + filterFns: { includesString: filterFn_includesString }, + sortFns: { basic: sortFn_basic }, +}) + +type TradingTableInstance = ReturnType< + typeof useTable +> +type TradingRow = ReturnType< + TradingTableInstance['getRowModel'] +>['rows'][number] + +interface ColumnDragRuntime { + columnId: string | null + sourceElement: HTMLTableCellElement | null + targetElement: HTMLTableCellElement | null +} + +const TradingRowView = defineComponent({ + name: 'TradingRowView', + props: { + row: { type: Object as PropType, required: true }, + virtualRow: Object as PropType, + }, + setup(props, { slots }) { + const selectedSymbol = useTradingShellController().selectedSymbol + return () => ( + + {slots.default?.()} + + ) + }, +}) + +export const TradingTable = defineComponent({ + name: 'TradingTable', + setup() { + const controller = useTradingShellController() + const feed = useMarketFeedController() + const virtualScrollMode = computed(() => + resolveVirtualScrollMode( + controller.requestedVirtualScrollMode.value, + feed.instrumentCount.value, + ), + ) + const table = useTable({ + key: 'vue-realtime-trading', + features, + columns: tradingColumns, + data: feed.quotes, + getRowId: (row: MarketQuote) => row.id, + columnResizeMode: 'onChange', + defaultColumn: { minSize: 56, maxSize: 800 }, + autoResetCellSelection: false, + }) + const rows = computed(() => { + void feed.quotes.value + void table.atoms.sorting.get() + void table.atoms.columnFilters.get() + void table.atoms.columnOrder.get() + return readMeasuredRows(() => table.getRowModel().rows) + }) + const scrollElement = ref(null) + const tableElement = ref(null) + const virtualizer = useVirtualizer( + computed(() => ({ + count: rows.value.length, + estimateSize: () => TRADING_ROW_HEIGHT, + getScrollElement: () => scrollElement.value, + getItemKey: (index: number) => rows.value[index]?.id ?? index, + overscan: TRADING_ROW_OVERSCAN, + enabled: virtualScrollMode.value === 'tanstack', + })), + ) + const virtualRows = computed(() => virtualizer.value.getVirtualItems()) + const visibleRange = computed(() => { + const range = virtualizer.value.range + if ( + virtualScrollMode.value !== 'tanstack' || + rows.value.length === 0 || + range === null + ) { + return null + } + const lastIndex = rows.value.length - 1 + const start = Math.min(range.startIndex, lastIndex) + return { + start, + end: Math.min(Math.max(start, range.endIndex), lastIndex), + } + }) + const pointerInteractions = new TradingGridPointerController() + const dragRuntime: ColumnDragRuntime = { + columnId: null, + sourceElement: null, + targetElement: null, + } + const layoutRuntime = { manuallyResized: false } + + useTableBenchmark(controller) + onUpdated(() => feed.completeRender()) + onMounted(() => feed.completeRender()) + + const writeColumnSizes = (): void => { + const element = tableElement.value + if (!element) return + for (const header of table.getFlatHeaders()) { + element.style.setProperty( + `--header-${header.id}-size`, + String(header.getSize()), + ) + element.style.setProperty( + `--col-${header.column.id}-size`, + String(header.column.getSize()), + ) + } + element.style.width = `${table.getTotalSize()}px` + } + const fitAvailableWidth = (): void => { + const container = scrollElement.value + if (!container || layoutRuntime.manuallyResized) return + const currentWidth = table.getTotalSize() + if (container.clientWidth <= currentWidth + 1 || currentWidth <= 0) return + const ratio = container.clientWidth / currentWidth + table.setColumnSizing( + Object.fromEntries( + table + .getVisibleLeafColumns() + .map((column) => [column.id, column.getSize() * ratio]), + ), + ) + } + const resizeObserver = new ResizeObserver(fitAvailableWidth) + const stopLayoutWatch = watch( + [ + () => table.atoms.columnSizing.get(), + () => table.atoms.columnOrder.get(), + ], + writeColumnSizes, + { flush: 'post' }, + ) + const stopResizingWatch = watch( + () => table.atoms.columnResizing.get().isResizingColumn, + (resizingColumn) => { + if (resizingColumn !== false) layoutRuntime.manuallyResized = true + }, + { flush: 'sync' }, + ) + onMounted(() => { + nextTick(() => { + writeColumnSizes() + fitAvailableWidth() + if (scrollElement.value) resizeObserver.observe(scrollElement.value) + }) + }) + onBeforeUnmount(() => { + stopLayoutWatch() + stopResizingWatch() + resizeObserver.disconnect() + }) + watchEffect(() => { + controller.actions.setRenderedRowCount( + virtualScrollMode.value === 'tanstack' + ? virtualRows.value.length + : rows.value.length, + ) + }) + + const clearColumnDrag = (): void => { + dragRuntime.sourceElement?.classList.remove('is-column-dragging') + dragRuntime.targetElement?.classList.remove('is-column-drop-target') + dragRuntime.columnId = null + dragRuntime.sourceElement = null + dragRuntime.targetElement = null + } + const showColumnDropTarget = ( + columnId: string, + element: HTMLTableCellElement | null, + ): void => { + dragRuntime.targetElement?.classList.remove('is-column-drop-target') + dragRuntime.targetElement = null + if (dragRuntime.columnId === columnId || !element) return + element.classList.add('is-column-drop-target') + dragRuntime.targetElement = element + } + + const renderRow = (row: TradingRow, virtualRow?: VirtualItem) => ( + + {{ + default: () => + row.getVisibleCells().map((cell) => { + const edges = cell.getSelectionEdges() + return ( + + + + ) + }), + }} + + ) + + return () => { + const currentRows = rows.value + const activeVirtualRows = virtualRows.value + return ( + <> +
handleCellNavigation(table, event)} + > + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isLeaf = header.subHeaders.length === 0 + const sorted = header.column.getIsSorted() + return ( + + ) + })} + + ))} + + + pointerInteractions.handleMouseDown( + table, + event, + controller.actions.selectSymbol, + ) + } + onPointerover={(event) => + pointerInteractions.handlePointerOver(table, event) + } + onMouseleave={() => pointerInteractions.resetPointerCell()} + onClick={(event) => + pointerInteractions.handleClick(table, event) + } + > + {virtualScrollMode.value === 'tanstack' + ? activeVirtualRows.map((virtualRow) => + renderRow(currentRows[virtualRow.index], virtualRow), + ) + : currentRows.map((row) => renderRow(row))} + +
+ {!header.isPlaceholder && + (isLeaf ? ( + <> +
{ + event.preventDefault() + showColumnDropTarget( + header.column.id, + ( + event.currentTarget as HTMLElement + ).closest('th'), + ) + }} + onDrop={(event) => { + event.preventDefault() + const sourceId = + event.dataTransfer?.getData( + 'text/plain', + ) || dragRuntime.columnId + if (sourceId) { + table.setColumnOrder( + reorderColumnIds( + table + .getVisibleLeafColumns() + .map((column) => column.id), + sourceId, + header.column.id, + ), + ) + } + clearColumnDrag() + }} + > + + +
+ {header.column.getCanResize() && ( +
+
+ {virtualScrollMode.value === 'tanstack' && ( +
+ + TanStack · Total · {currentRows.length} rows ·{' '} + {table.getVisibleLeafColumns().length} columns + + + {visibleRange.value + ? `Current · rows ${visibleRange.value.start}..${visibleRange.value.end}` + : 'Current · rows —'} + +
+ )} + + ) + } + }, +}) + +function isTextColumn(columnId: string): boolean { + return columnId === 'market' || columnId === 'name' || columnId === 'symbol' +} diff --git a/examples/vue/realtime-trading/src/vite-env.d.ts b/examples/vue/realtime-trading/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/vue/realtime-trading/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts b/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000000..c56f09558f --- /dev/null +++ b/examples/vue/realtime-trading/tests/e2e/smoke.spec.ts @@ -0,0 +1,182 @@ +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' + +const exampleDir = path.resolve() + +function collectPageErrors(page: Page) { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + return errors +} + +test('runs the Vue realtime trading workload', async ({ page }) => { + const server = await startExampleServer(exampleDir) + const errors = collectPageErrors(page) + + try { + await page.goto(server.url) + + const table = page.getByTestId('trading-table') + const instrumentCount = page.getByTestId('instrument-count-select') + const virtualScrollSelect = page.getByTestId('virtual-scroll-select') + await expect(table).toBeVisible() + await expect(instrumentCount).toHaveValue('100') + await expect(page.locator('.brand')).toHaveText('MARKET MONITOR') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('tbody')).toHaveCSS('user-select', 'none') + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await instrumentCount.selectOption('250') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeEnabled() + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(250) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await instrumentCount.selectOption('100') + await expect(virtualScrollSelect).toHaveValue('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await virtualScrollSelect.selectOption('tanstack') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(100) + await expect(table.locator('tbody tr').first()).toHaveCSS( + 'content-visibility', + 'auto', + ) + await expect(page.getByTestId('virtual-scroll-footer')).toBeVisible() + await virtualScrollSelect.selectOption('none') + await expect(table.locator('tbody tr')).toHaveCount(100) + await instrumentCount.selectOption('1500') + await expect(virtualScrollSelect).toHaveValue('tanstack') + await expect(virtualScrollSelect).toBeDisabled() + await expect + .poll(() => table.locator('tbody tr').count()) + .toBeLessThan(1500) + await expect(page.getByTestId('virtual-scroll-footer')).toContainText( + 'Total · 1500 rows · 14 columns', + ) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows 0\.\.\d+\s*$/, + ) + await table.locator('..').evaluate((element) => { + element.scrollTop = 2_000 + element.dispatchEvent(new Event('scroll')) + }) + await expect + .poll(async () => + Number( + await table + .locator('tbody tr') + .first() + .getAttribute('data-virtual-index'), + ), + ) + .toBeGreaterThan(0) + await expect(page.getByTestId('visible-row-range')).toHaveText( + /^\s*Current · rows [1-9]\d*\.\.\d+\s*$/, + ) + await instrumentCount.selectOption('100') + await expect(page.getByTestId('virtual-scroll-footer')).toHaveCount(0) + await expect(virtualScrollSelect).toHaveValue('none') + await expect(virtualScrollSelect).toBeEnabled() + await expect(table.locator('tbody tr')).toHaveCount(100) + await expect(table.locator('thead tr')).toHaveCount(2) + await expect(table.locator('thead tr').last().locator('th')).toHaveCount(14) + await expect(table.locator('thead')).not.toContainText('Identity') + await expect(table.locator('thead')).not.toContainText('Market Data') + await expect(table.locator('thead')).toContainText('Market') + await expect(table.locator('thead')).toContainText('Bid Vol') + await expect(table.locator('thead')).toContainText('Intraday') + const selectedRow = table.locator('tbody tr').first() + const selectedSymbol = await selectedRow.getAttribute('data-symbol') + await selectedRow.locator('td').nth(1).click() + await expect(page.getByTestId('selected-instrument')).toContainText( + selectedSymbol ?? '', + ) + await expect(page.getByTestId('feed-status')).toHaveText('FEED LIVE') + await expect(instrumentCount.locator('option[value="150"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="350"]')).toHaveCount(1) + await expect(instrumentCount.locator('option[value="750"]')).toHaveCount(1) + + const targetRateSlider = page.getByTestId('target-rate-slider') + await expect(targetRateSlider).toHaveAttribute('min', '0') + await expect(targetRateSlider).toHaveAttribute('max', '9') + await expect(targetRateSlider).toHaveAttribute('step', '1') + await expect(targetRateSlider).toHaveValue('6') + await targetRateSlider.fill('7') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '25K samples/s', + ) + await targetRateSlider.fill('8') + await expect(page.getByTestId('target-sample-rate')).toContainText( + '50K samples/s', + ) + await targetRateSlider.fill('6') + + const sparklineInterval = page.getByTestId( + 'sparkline-sample-interval-select', + ) + await expect(sparklineInterval).toHaveValue('16') + await sparklineInterval.selectOption('100') + await expect(sparklineInterval).toHaveValue('100') + await sparklineInterval.selectOption('16') + + const publishInterval = page.getByTestId('publish-interval-select') + await expect(publishInterval.locator('option[value="500"]')).toHaveCount(1) + await expect(publishInterval.locator('option[value="1000"]')).toHaveCount(1) + + await expect + .poll(async () => { + const text = await page.getByTestId('row-update-rate').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => { + const text = await page.getByTestId('worker-messages').textContent() + return Number(text?.replace(/\D/g, '') ?? 0) + }) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('message-rate').textContent()), + ) + .toBeGreaterThan(0) + await expect + .poll(async () => + Number(await page.getByTestId('table-render-rate').textContent()), + ) + .toBeGreaterThan(0) + + const firstPrice = page.locator('tbody tr').first().getByRole('button') + const priceBeforeUpdate = await firstPrice.textContent() + await expect + .poll(() => firstPrice.textContent()) + .not.toBe(priceBeforeUpdate) + + await page.locator('.config-section input[type="checkbox"]').first().check() + await page.getByTestId('feed-toggle').click() + await expect(page.getByTestId('feed-toggle')).toHaveText('START FEED') + await expect(page.getByTestId('feed-status')).toHaveText('FEED PAUSED') + + await instrumentCount.selectOption('750') + await expect.poll(() => table.locator('tbody tr').count()).toBeLessThan(750) + expect( + await page.evaluate( + () => performance.getEntriesByName('tanstack-row-model').length > 0, + ), + ).toBe(true) + + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/vue/realtime-trading/tsconfig.json b/examples/vue/realtime-trading/tsconfig.json new file mode 100644 index 0000000000..709980136e --- /dev/null +++ b/examples/vue/realtime-trading/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "jsxImportSource": "vue", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src", "tests/e2e", "vite.config.ts"] +} diff --git a/examples/vue/realtime-trading/vite.config.ts b/examples/vue/realtime-trading/vite.config.ts new file mode 100644 index 0000000000..8a0fa94d84 --- /dev/null +++ b/examples/vue/realtime-trading/vite.config.ts @@ -0,0 +1,11 @@ +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 7781, + allowedHosts: true, + }, + plugins: [vue(), vueJsx()], +}) diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 24f2f30fe7..9c592f369f 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -56,6 +56,7 @@ "scripts": { "build": "ng-packagr -p ng-package.json -c tsconfig.build.json && rimraf ./dist/package.json && find dist -name '*.map' -delete", "build:types": "tsc --emitDeclarationOnly", + "bench:flex-render": "vitest bench --run tests/flex-render/flex-render.bench.ts", "clean": "rimraf ./build && rimraf ./dist", "test:build": "publint --strict", "test:eslint": "eslint ./src", diff --git a/packages/angular-table/src/flex-render/flags.ts b/packages/angular-table/src/flex-render/flags.ts index e265c847c8..6a26987f8b 100644 --- a/packages/angular-table/src/flex-render/flags.ts +++ b/packages/angular-table/src/flex-render/flags.ts @@ -1,34 +1,33 @@ /** - * Flags used to manage and optimize the rendering lifecycle of the content of the cell - * while using {@link FlexViewRenderer}. + * Flags used to manage and optimize the rendering lifecycle of content inside + * {@link FlexViewRenderer}. */ export const FlexRenderFlags = { /** - * Indicates that the view is being created for the first time or will be cleared during the next update phase. - * This is the initial state and will transition after the first ngDoCheck. + * The renderer has not completed its initial update. The first update creates + * the view from scratch, then clears this flag. */ ViewFirstRender: 1 << 0, /** - * Indicates the `content` property has been modified or the view requires a complete re-render. - * When this flag is enabled, the view will be cleared and recreated from scratch. + * The `content` input changed by reference, or its resolved value is not + * compatible with the mounted view. The next update recreates the view. */ ContentChanged: 1 << 1, /** - * Indicates that the `props` property reference has changed. - * When this flag is enabled, the view context is updated based on the type of the content. - * - * For Component view, inputs will be updated and view will be marked as dirty. - * For TemplateRef and primitive values, view will be marked as dirty + * The `props` input changed by reference. Components receive the latest + * inputs and embedded templates are marked so their getter-backed context is + * evaluated again. */ PropsReferenceChanged: 1 << 2, /** - * Indicates that the current rendered view needs to be checked for changes. - * This will be set to true when `content(props)` result has changed or during - * forced update + * A render function produced compatible content that must be synchronized + * with the mounted view without recreating it. */ Dirty: 1 << 3, /** - * Indicates that the first render effect has been checked at least one time. + * The render-function effect completed its initial dependency read. That + * first execution records dependencies; subsequent executions update the + * view. */ RenderEffectChecked: 1 << 4, } as const diff --git a/packages/angular-table/src/flex-render/flexRenderComponent.ts b/packages/angular-table/src/flex-render/flexRenderComponent.ts index 5dc958c352..396b2a0017 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponent.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponent.ts @@ -17,11 +17,35 @@ interface FlexRenderOptions< TInputs extends Record, TOutputs extends Record, > { + /** + * Optional identity used to control component instance reuse. + * + * A rendered component is reused while both its component type and key are + * unchanged. Change the key to explicitly destroy and recreate the component, + * for example when new creation-time bindings, directives, or an injector + * need to be applied. + * + * Inputs and outputs do not affect component identity and are synchronized + * onto a reused component instance. + * + * @example + * ```ts + * flexRenderComponent(EditorComponent, { + * key: row.original.editorVersion, + * inputs: { value: row.original.value }, + * }) + * ``` + */ + readonly key?: string | number /** * Native Angular bindings applied at component creation time via `createComponent`. * Use this option to set inputs, outputs, or two-way bindings at creation time. * Shouldn't be used together with {@link FlexRenderOptions#inputs} or {@link FlexRenderOptions#outputs} option. * + * Bindings are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new bindings. + * * Binding input/outputs at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -54,6 +78,10 @@ interface FlexRenderOptions< /** * Directives to apply to the component at creation time. * + * Directives are creation-time configuration. Changing this array after the + * component has mounted does not update the existing component. Change + * {@link FlexRenderOptions#key} to recreate the component with new directives. + * * Binding directives at creation time: {@link https://angular.dev/guide/components/programmatic-rendering#binding-inputs-outputs-and-setting-host-directives-at-creation} * * Two-way binding: {@link https://angular.dev/api/core/twoWayBinding} @@ -88,6 +116,8 @@ interface FlexRenderOptions< * * These values are assigned after the component has been created using * [componentRef.setInput API](https://angular.dev/api/core/ComponentRef#setInput). + * On a reused component, omitted keys keep their current value. Pass + * `undefined` explicitly when an input needs to be cleared. * * Shouldn't be used together with {@link FlexRenderOptions#bindings} option */ @@ -101,7 +131,11 @@ interface FlexRenderOptions< */ readonly outputs?: TOutputs /** - * Optional {@link Injector} that will be used when rendering the component + * Optional {@link Injector} that will be used when rendering the component. + * + * The injector is applied when the component is created. Change + * {@link FlexRenderOptions#key} to recreate a mounted component with a + * different injector. */ readonly injector?: Injector } @@ -151,7 +185,7 @@ export function flexRenderComponent( component: Type, options?: FlexRenderOptions, Outputs>, ): FlexRenderComponent { - const { inputs, injector, outputs, directives, bindings } = options ?? {} + const { key, inputs, injector, outputs, directives, bindings } = options ?? {} return new FlexRenderComponentInstance( component, inputs, @@ -159,6 +193,7 @@ export function flexRenderComponent( outputs, directives, bindings, + key, ) } @@ -208,17 +243,20 @@ export interface FlexRenderComponent { */ readonly component: Type /** - * Reflected metadata about the component. + * Optional identity used together with the component type to decide whether + * an existing component instance can be reused. + * + * @see {@link FlexRenderOptions#key} */ - readonly mirror: ComponentMirror + readonly key?: string | number /** - * List of allowed input names. + * Reflected metadata about the component. */ - readonly allowedInputNames: Array + readonly mirror: ComponentMirror /** - * List of allowed output names. + * Cached component metadata used by the flex renderer. */ - readonly allowedOutputNames: Array + readonly metadata: ResolvedComponentMetadata /** * Component instance outputs. Subscribed via {@link OutputEmitterRef#subscribe} * @@ -254,14 +292,13 @@ export interface FlexRenderComponent { /** * Wrapper class for a component that will be used as content for {@link FlexRenderDirective} * - * Prefer {@link flexRenderComponent} helper for better type-safety + * Prefer {@link flexRenderComponent} for better type-safety. */ export class FlexRenderComponentInstance< TComponent = any, > implements FlexRenderComponent { readonly mirror: ComponentMirror - readonly allowedInputNames: Array = [] - readonly allowedOutputNames: Array = [] + readonly metadata: ResolvedComponentMetadata constructor( readonly component: Type, @@ -270,19 +307,46 @@ export class FlexRenderComponentInstance< readonly outputs?: Outputs, readonly directives?: CreateComponentDirectives, readonly bindings?: CreateComponentBindings, + readonly key?: string | number, ) { - const mirror = reflectComponentType(component) - if (!mirror) { - throw new Error( - `[@tanstack-table/angular] The provided symbol is not a component`, - ) - } - this.mirror = mirror - for (const input of this.mirror.inputs) { - this.allowedInputNames.push(input.propName) - } - for (const output of this.mirror.outputs) { - this.allowedOutputNames.push(output.propName) + this.metadata = resolveComponentTypeMetadata(component) + this.mirror = this.metadata.mirror + } +} + +interface ResolvedComponentMetadata { + readonly mirror: ComponentMirror + readonly inputNames: ReadonlyMap + readonly outputNames: ReadonlySet +} + +const typeCache = new WeakMap, ResolvedComponentMetadata>() + +function resolveComponentTypeMetadata( + type: Type, +): ResolvedComponentMetadata { + let metadata = typeCache.get(type) as ResolvedComponentMetadata | undefined + if (metadata) return metadata + const mirror = reflectComponentType(type) + if (!mirror) { + throw new Error( + `[@tanstack-table/angular] The provided symbol is not a component`, + ) + } + const inputNames = new Map() + const outputNames = new Set() + for (const input of mirror.inputs) { + inputNames.set(input.propName, input.templateName) + if (input.templateName !== input.propName) { + inputNames.set(input.templateName, input.templateName) } } + for (const output of mirror.outputs) { + // Outputs are read from the component instance, so only their class + // property names are valid here. Template aliases are not instance keys. + outputNames.add(output.propName) + } + metadata = { mirror, inputNames, outputNames } + typeCache.set(type, metadata) + return metadata } diff --git a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts index 0cd39c79ea..2e1148f310 100644 --- a/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts +++ b/packages/angular-table/src/flex-render/flexRenderComponentFactory.ts @@ -3,13 +3,11 @@ import { ComponentRef, Injectable, Injector, - KeyValueDiffer, - KeyValueDiffers, OutputEmitterRef, OutputRefSubscription, ViewContainerRef, } from '@angular/core' -import { FlexRenderComponent } from './flexRenderComponent' +import type { FlexRenderComponent } from './flexRenderComponent' /** * Creates and manages Angular component instances used by flex-rendered table @@ -32,7 +30,7 @@ export class FlexRenderComponentFactory { { injector: componentInjector, directives: flexRenderComponent.directives, - bindings: flexRenderComponent.bindings ?? [], + bindings: flexRenderComponent.bindings, }, ) const view = new FlexRenderComponentRef( @@ -57,10 +55,8 @@ export class FlexRenderComponentFactory { * be reused instead of recreated on every cell/header render. */ export class FlexRenderComponentRef { - readonly #keyValueDiffersFactory: KeyValueDiffers #componentData: FlexRenderComponent - #inputValueDiffer: KeyValueDiffer - + readonly #creationKey: FlexRenderComponent['key'] readonly #outputRegistry: FlexRenderComponentOutputManager constructor( @@ -69,18 +65,8 @@ export class FlexRenderComponentRef { readonly componentInjector: Injector, ) { this.#componentData = componentData - this.#keyValueDiffersFactory = componentInjector.get(KeyValueDiffers) - - this.#outputRegistry = new FlexRenderComponentOutputManager( - this.#keyValueDiffersFactory, - this.outputs, - ) - - this.#inputValueDiffer = this.#keyValueDiffersFactory - .find(this.inputs) - .create() - this.#inputValueDiffer.diff(this.inputs) - + this.#creationKey = componentData.key + this.#outputRegistry = new FlexRenderComponentOutputManager() this.componentRef.onDestroy(() => this.#outputRegistry.unsubscribeAll()) } @@ -96,15 +82,6 @@ export class FlexRenderComponentRef { return this.#componentData.outputs ?? {} } - /** - * Get component input and output diff by the given item - */ - diff(item: FlexRenderComponent) { - return { - inputDiff: this.#inputValueDiffer.diff(item.inputs ?? {}), - outputDiff: this.#outputRegistry.diff(item.outputs ?? {}), - } - } /** * * @param compare Whether the current ref component instance is the same as the given one @@ -113,37 +90,18 @@ export class FlexRenderComponentRef { return compare.component === this.component } + canReuse(compare: FlexRenderComponent): boolean { + return this.eqType(compare) && Object.is(compare.key, this.#creationKey) + } + /** * Tries to update current component refs input by the new given content component. */ - update(content: FlexRenderComponent) { - const eq = this.eqType(content) - if (!eq) return - const { inputDiff, outputDiff } = this.diff(content) - if (inputDiff) { - inputDiff.forEachAddedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachChangedItem((item) => - this.setInput(item.key, item.currentValue), - ) - inputDiff.forEachRemovedItem((item) => this.setInput(item.key, undefined)) - } - if (outputDiff) { - outputDiff.forEachAddedItem((item) => { - this.setOutput(item.key, item.currentValue) - }) - outputDiff.forEachChangedItem((item) => { - if (item.currentValue) { - this.#outputRegistry.setListener(item.key, item.currentValue) - } else { - this.#outputRegistry.unsubscribe(item.key) - } - }) - outputDiff.forEachRemovedItem((item) => { - this.#outputRegistry.unsubscribe(item.key) - }) - } + update(content: FlexRenderComponent): void { + if (!this.canReuse(content)) return + + this.#syncInputs(content.inputs ?? {}) + this.#syncOutputs(content.outputs ?? {}) this.#componentData = content } @@ -153,15 +111,15 @@ export class FlexRenderComponentRef { } setInputs(inputs: Record) { - for (const prop in inputs) { + for (const prop of Object.keys(inputs)) { this.setInput(prop, inputs[prop]) } } setInput(key: string, value: unknown) { - if (this.#componentData.allowedInputNames.includes(key)) { - this.componentRef.setInput(key, value) - } + const inputName = this.#componentData.metadata.inputNames.get(key) + if (inputName === undefined) return + this.componentRef.setInput(inputName, value) } setOutputs( @@ -171,81 +129,106 @@ export class FlexRenderComponentRef { >, ) { this.#outputRegistry.unsubscribeAll() - for (const prop in outputs) { + for (const prop of Object.keys(outputs)) { this.setOutput(prop, outputs[prop]) } } setOutput( - outputName: string, + key: string, emit: OutputEmitterRef['emit'] | undefined | null, ): void { - if (!this.#componentData.allowedOutputNames.includes(outputName)) return + if (!this.#componentData.metadata.outputNames.has(key)) return + const outputName = key if (!emit) { this.#outputRegistry.unsubscribe(outputName) return } - const hasListener = this.#outputRegistry.hasListener(outputName) + // If the output was already subscribed, just swap the listener callback. + const hasSubscription = this.#outputRegistry.hasSubscription(outputName) this.#outputRegistry.setListener(outputName, emit) - if (hasListener) { + if (hasSubscription) { return } const instance = this.componentRef.instance const output = instance[outputName as keyof typeof instance] if (output && output instanceof OutputEmitterRef) { - output.subscribe((value) => { - this.#outputRegistry.getListener(outputName)?.(value) - }) + this.#outputRegistry.setSubscription( + outputName, + output.subscribe((value) => { + this.#outputRegistry.getListener(outputName)?.(value) + }), + ) + } + } + + #syncInputs(newInputs: Record): void { + // Inputs use patch semantics: omitted keys keep their current value, while + // an explicitly provided `undefined` is forwarded to Angular. + for (const prop of Object.keys(newInputs)) { + this.setInput(prop, newInputs[prop]) + } + } + + #syncOutputs( + outputs: Record< + string, + OutputEmitterRef['emit'] | null | undefined + >, + ): void { + const outputKeys = Object.keys(outputs) + const currentSubscribedKeys = this.#outputRegistry.getSubscribedKeys() + // When outputs updates, unsubscribe missing keys + for (const key of currentSubscribedKeys) { + if (!outputKeys.includes(key)) { + this.#outputRegistry.unsubscribe(key) + } + } + for (const prop of outputKeys) { + this.setOutput(prop, outputs[prop]) } } } class FlexRenderComponentOutputManager { - readonly #outputSubscribers: Record = {} - readonly #outputListeners: Record) => void> = {} - - readonly #valueDiffer: KeyValueDiffer< - string, - undefined | null | OutputEmitterRef['emit'] - > - - constructor(keyValueDiffers: KeyValueDiffers, initialOutputs: any) { - this.#valueDiffer = keyValueDiffers.find(initialOutputs).create() - if (initialOutputs) { - this.#valueDiffer.diff(initialOutputs) - } + readonly #outputSubscribers = new Map() + readonly #outputListeners = new Map) => void>() + + getSubscribedKeys() { + return Array.from(this.#outputListeners.keys()) } - hasListener(outputName: string) { - return outputName in this.#outputListeners + hasSubscription(outputName: string) { + return this.#outputSubscribers.has(outputName) } setListener(outputName: string, callback: (...args: Array) => void) { - this.#outputListeners[outputName] = callback + this.#outputListeners.set(outputName, callback) } getListener(outputName: string) { - return this.#outputListeners[outputName] + return this.#outputListeners.get(outputName) } - unsubscribeAll(): void { - for (const prop in this.#outputSubscribers) { - this.unsubscribe(prop) - } + setSubscription( + outputName: string, + subscription: OutputRefSubscription, + ): void { + this.#outputSubscribers.set(outputName, subscription) } - unsubscribe(outputName: string) { - if (outputName in this.#outputSubscribers) { - this.#outputSubscribers[outputName]?.unsubscribe() - delete this.#outputSubscribers[outputName] - delete this.#outputListeners[outputName] + unsubscribeAll(): void { + for (const outputName of this.#outputListeners.keys()) { + this.unsubscribe(outputName) } } - diff(outputs: Record['emit'] | undefined>) { - return this.#valueDiffer.diff(outputs) + unsubscribe(outputName: string) { + this.#outputSubscribers.get(outputName)?.unsubscribe() + this.#outputSubscribers.delete(outputName) + this.#outputListeners.delete(outputName) } } diff --git a/packages/angular-table/src/flex-render/renderer.ts b/packages/angular-table/src/flex-render/renderer.ts index 20064719ff..205bb0a196 100644 --- a/packages/angular-table/src/flex-render/renderer.ts +++ b/packages/angular-table/src/flex-render/renderer.ts @@ -114,6 +114,7 @@ export class FlexViewRenderer< FlexRenderViewAllowedType, FlexRenderTypedContent > | null = null + #outerRenderEffectRef: EffectRef | null = null #currentRenderEffectRef: EffectRef | null = null #content: () => FlexRenderInputContent #props: () => TProps @@ -132,9 +133,8 @@ export class FlexViewRenderer< readonly #latestContent = computed(() => this.#getLatestContentValue()) - #getContentValue = computed(() => { - const latestContent = this.#latestContent() - return mapToFlexRenderTypedContent(latestContent) + readonly #getContentValue = computed(() => { + return mapToFlexRenderTypedContent(this.#latestContent()) }) constructor(options: RendererViewOptions) { @@ -149,45 +149,66 @@ export class FlexViewRenderer< } mount(): EffectRef { - let previousContent: FlexRenderInputContent - let previousProps: TProps - - return effect(() => { - const props = this.#props() - const content = this.#content() + if (this.#outerRenderEffectRef) { + return this.#outerRenderEffectRef + } - if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { - if (previousContent !== content) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } - if (previousProps !== props) { - this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + let previousContent: FlexRenderInputContent | undefined + let previousProps: TProps | undefined + + this.#outerRenderEffectRef = effect( + () => { + const props = this.#props() + const content = this.#content() + + if (!(this.#renderFlags & FlexRenderFlags.ViewFirstRender)) { + if (previousContent !== content) { + // A new content input may install a different render function (or + // stop rendering a function), so its dependency effect must be + // replaced. Incompatible values returned by the same function only + // recreate the view and keep the existing effect. + this.#destroyContentEffect() + this.#renderFlags |= FlexRenderFlags.ContentChanged + } + if (previousProps !== props) { + this.#renderFlags |= FlexRenderFlags.PropsReferenceChanged + } } - } - untracked(() => this.#update()) + untracked(() => this.#update()) - if (FlexRenderFlags.ViewFirstRender & this.#renderFlags) { - this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender - } + if (this.#renderFlags & FlexRenderFlags.ViewFirstRender) { + this.#renderFlags &= ~FlexRenderFlags.ViewFirstRender + } - previousContent = content - previousProps = props - }) + previousContent = content + previousProps = props + }, + { injector: this.#viewContainerRef.injector }, + ) + + return this.#outerRenderEffectRef } destroy(): void { + if (this.#outerRenderEffectRef) { + this.#outerRenderEffectRef.destroy() + this.#outerRenderEffectRef = null + } + this.#destroyContentEffect() + this.#destroyView() + this.#renderFlags = FlexRenderFlags.ViewFirstRender + } + + #destroyContentEffect(): void { if (this.#currentRenderEffectRef) { this.#currentRenderEffectRef.destroy() this.#currentRenderEffectRef = null } - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null - } + this.#renderFlags &= ~FlexRenderFlags.RenderEffectChecked } - #update() { + #update(): void { if ( this.#renderFlags & (FlexRenderFlags.ContentChanged | FlexRenderFlags.ViewFirstRender) @@ -197,85 +218,73 @@ export class FlexViewRenderer< } if (this.#renderFlags & FlexRenderFlags.PropsReferenceChanged) { - if (this.#renderView) this.#renderView.updateProps(this.#props()) + this.#renderView?.updateProps(this.#props()) this.#renderFlags &= ~FlexRenderFlags.PropsReferenceChanged } if (this.#renderFlags & FlexRenderFlags.Dirty) { - if (this.#renderView) this.#renderView.dirtyCheck() + this.#renderView?.dirtyCheck() this.#renderFlags &= ~FlexRenderFlags.Dirty } } - #render() { - // When the view is recreated from scratch (content change or first render), - // we have to destroy the current effect listener since it will be recreated - // skipping the first call (FlexRenderFlags.RenderEffectChecked) - if (this.#shouldRecreateEntireView() && this.#currentRenderEffectRef) { - this.#currentRenderEffectRef.destroy() - this.#currentRenderEffectRef = null - this.#renderFlags &= ~FlexRenderFlags.RenderEffectChecked - } + #render(): void { + // Resolved content can require a new view without changing the render + // function. Preserve its effect and checked state across that replacement. + this.#destroyView() - this.#viewContainerRef.clear() - if (this.#renderView) { - this.#renderView.unmount() - this.#renderView = null - } + this.#renderFlags &= + FlexRenderFlags.ViewFirstRender | FlexRenderFlags.RenderEffectChecked - this.#renderFlags = - (this.#renderFlags & FlexRenderFlags.ViewFirstRender) | - (this.#renderFlags & FlexRenderFlags.RenderEffectChecked) + const content = this.#getContentValue() + this.#renderView = this.#renderViewByContent(content) - const resolvedContent = this.#getContentValue() - this.#renderView = this.#renderViewByContent(resolvedContent) - // If the content is a function `content(props)`, we initialize an effect - // to react to changes. If the current fn uses signals, we will set the DirtySignal flag - // to re-schedule the component updates + // Render functions can read signals. Keep their dependency tracking in a + // dedicated effect so the outer effect remains responsible only for + // content and props input-reference changes. if ( !this.#currentRenderEffectRef && typeof untracked(this.#content) === 'function' ) { this.#currentRenderEffectRef = effect( () => { - this.#latestContent() + const latestContent = this.#getContentValue() if (!(this.#renderFlags & FlexRenderFlags.RenderEffectChecked)) { this.#renderFlags |= FlexRenderFlags.RenderEffectChecked return } - this.#renderFlags |= FlexRenderFlags.Dirty - this.#doCheck() + + untracked(() => { + this.#renderFlags |= FlexRenderFlags.Dirty + this.#doCheck(latestContent) + }) }, { injector: this.#viewContainerRef.injector }, ) } } - #shouldRecreateEntireView() { - return ( - this.#renderFlags & - FlexRenderFlags.ContentChanged & - FlexRenderFlags.ViewFirstRender - ) - } - - #doCheck() { - const latestContent = this.#getContentValue() - if (latestContent.kind === 'null' || !this.#renderView) { + #doCheck(latestContent: FlexRenderTypedContent): void { + if ( + latestContent.kind === 'null' || + !this.#renderView || + !this.#renderView.canReuse(latestContent) + ) { this.#renderFlags |= FlexRenderFlags.ContentChanged } else { - const { kind: currentKind } = this.#renderView.content - if ( - latestContent.kind !== currentKind || - !this.#renderView.eq(latestContent) - ) { - this.#renderFlags |= FlexRenderFlags.ContentChanged - } this.#renderView.content = latestContent } + this.#update() } + #destroyView(): void { + if (this.#renderView) { + this.#renderView.unmount() + this.#renderView = null + } + } + #renderViewByContent( content: FlexRenderTypedContent, ): FlexRenderView | null { @@ -287,25 +296,20 @@ export class FlexViewRenderer< return this.#renderComponent(content) } else if (content.kind === 'component') { return this.#renderCustomComponent(content) - } else { - return null } + return null } #renderStringContent( template: Extract, ): FlexRenderTemplateView { - const context = () => { - const content = this.#content() - return typeof content === 'string' || typeof content === 'number' - ? content - : runInInjectionContext(this.#injector(), () => - content?.(this.#props()), - ) - } + const latestContent = () => untracked(this.#getContentValue) const ref = this.#viewContainerRef.createEmbeddedView(this.#templateRef, { get $implicit() { - return context() + // The view can be checked while an incompatible replacement is being + // scheduled. Only expose content that still belongs to this context. + const content = latestContent() + return content.kind === 'primitive' ? content.content : undefined }, }) return new FlexRenderTemplateView(template, ref) @@ -314,12 +318,12 @@ export class FlexViewRenderer< #renderTemplateRefContent( template: Extract, ): FlexRenderTemplateView { - const latestContext = () => this.#props() + const latestProps = () => untracked(this.#props) const view = this.#viewContainerRef.createEmbeddedView( template.content, { get $implicit() { - return latestContext() + return latestProps() }, }, { injector: this.#getInjector() }, @@ -333,8 +337,9 @@ export class FlexViewRenderer< { kind: 'flexRenderComponent' } >, ): FlexRenderComponentView { - const { injector } = flexRenderComponent.content - const componentInjector = this.#getInjector(injector) + const componentInjector = this.#getInjector( + flexRenderComponent.content.injector, + ) const view = this.#flexRenderComponentFactory.createComponent( flexRenderComponent.content, componentInjector, diff --git a/packages/angular-table/src/flex-render/view.ts b/packages/angular-table/src/flex-render/view.ts index 39e93c2675..902d0899ca 100644 --- a/packages/angular-table/src/flex-render/view.ts +++ b/packages/angular-table/src/flex-render/view.ts @@ -50,7 +50,6 @@ export abstract class FlexRenderView< TContent extends FlexRenderTypedContent, > { readonly view: TView - #previousContent: FlexRenderTypedContent | undefined #content: FlexRenderTypedContent protected constructor( @@ -61,16 +60,11 @@ export abstract class FlexRenderView< this.view = view } - get previousContent(): FlexRenderTypedContent { - return this.#previousContent ?? { kind: 'null' } - } - get content() { return this.#content } set content(content: FlexRenderTypedContent) { - this.#previousContent = this.#content this.#content = content } @@ -78,9 +72,7 @@ export abstract class FlexRenderView< abstract dirtyCheck(): void - abstract onDestroy(callback: Function): void - - abstract eq(view: TContent): boolean + abstract canReuse(content: TContent): boolean abstract unmount(): void } @@ -106,36 +98,35 @@ export class FlexRenderTemplateView extends FlexRenderView< } override updateProps(_props: Record) { - this.view.markForCheck() + if (this.content.kind === 'templateRef') { + // Template contexts are getter-backed. Mark the embedded view so Angular + // reads the latest props; the context object itself does not need to be + // replaced. + this.view.markForCheck() + } } override dirtyCheck() { - // Basically a no-op. When the view is created via EmbeddedViewRef, we don't need to do any manual update - // since this type of content has a proxy as a context, then every time the root component is checked for changes, - // the property getter will be re-evaluated. - // - // If in a future we need to manually mark the view as dirty, just uncomment next line - // this.view.markForCheck() + if (this.content.kind === 'primitive') { + // Primitive contexts are getter-backed too. The renderer has already + // memoized the new value, so checking the view is enough to refresh + // `$implicit` without mutating the context. + this.view.markForCheck() + } } override unmount() { this.view.destroy() } - override onDestroy(callback: Function) { - this.view.onDestroy(callback) - } - - override eq( + override canReuse( compare: Extract< FlexRenderTypedContent, { kind: 'primitive' | 'templateRef' } >, ): boolean { return ( - (this.content.kind === 'primitive' && - compare.kind === 'primitive' && - this.content.content === compare.content) || + (this.content.kind === 'primitive' && compare.kind === 'primitive') || (this.content.kind === 'templateRef' && compare.kind === 'templateRef' && this.content.content === compare.content) @@ -170,8 +161,8 @@ export class FlexRenderComponentView extends FlexRenderView< break } case 'flexRenderComponent': { - // No-op. When FlexRenderFlags.PropsReferenceChanged is set, - // FlexRenderComponent will be updated into `dirtyCheck`. + // No-op. A props change can produce a new wrapper descriptor; its + // inputs and outputs are synchronized by `dirtyCheck`. break } } @@ -187,8 +178,9 @@ export class FlexRenderComponentView extends FlexRenderView< break } case 'flexRenderComponent': { - // Given context instance will always have a different reference than the previous one, - // so instead of recreating the entire view, we will only update the current view + // Render functions commonly create a new descriptor on every run. If + // its type and key still identify the mounted instance, update that + // instance instead of recreating the component view. if (this.view.eqType(this.content.content)) { this.view.update(this.content.content) } @@ -202,11 +194,7 @@ export class FlexRenderComponentView extends FlexRenderView< this.view.componentRef.destroy() } - override onDestroy(callback: Function) { - this.view.componentRef.onDestroy(callback) - } - - override eq( + override canReuse( compare: Extract< FlexRenderTypedContent, { kind: 'component' | 'flexRenderComponent' } @@ -218,7 +206,7 @@ export class FlexRenderComponentView extends FlexRenderView< this.content.content === compare.content) || (this.content.kind === 'flexRenderComponent' && compare.kind === 'flexRenderComponent' && - this.content.content.component === compare.content.component) + this.view.canReuse(compare.content)) ) } } diff --git a/packages/angular-table/src/helpers/flexRenderCell.ts b/packages/angular-table/src/helpers/flexRenderCell.ts index 20f3a432f3..b96f02952d 100644 --- a/packages/angular-table/src/helpers/flexRenderCell.ts +++ b/packages/angular-table/src/helpers/flexRenderCell.ts @@ -130,12 +130,9 @@ export class FlexRenderCell< readonly #viewContainerRef = inject(ViewContainerRef) constructor() { - const content = computed(() => this.#renderData()[0]) - const props = computed(() => this.#renderData()[1]) - const renderer = new FlexViewRenderer({ - content: content, - props: props, + content: () => this.#renderData()[0], + props: () => this.#renderData()[1], injector: () => this.#injector, templateRef: this.#templateRef, viewContainerRef: this.#viewContainerRef, diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts index d35e0269ce..581e07f13e 100644 --- a/packages/angular-table/src/reactivity.ts +++ b/packages/angular-table/src/reactivity.ts @@ -21,9 +21,7 @@ function signalToReadonlyAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) @@ -47,9 +45,7 @@ function signalToWritableAtom( get: () => signal(), subscribe: (observer: Observer) => { return untracked(() => - toObservable(computed(signal), { injector: injector }).subscribe( - observer, - ), + toObservable(signal, { injector: injector }).subscribe(observer), ) }, }) diff --git a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index 698ec30ad7..638f90e400 100644 --- a/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -12,4 +12,10 @@ test('Infer component inputs', () => { // Input is optional so we can skip passing the property flexRenderComponent(Test, { inputs: {} }) + + flexRenderComponent(Test, { key: 'stable-key' }) + flexRenderComponent(Test, { key: 1 }) + + // @ts-expect-error Keys must have stable primitive identity + flexRenderComponent(Test, { key: {} }) }) diff --git a/packages/angular-table/tests/flex-render/flex-render.bench.ts b/packages/angular-table/tests/flex-render/flex-render.bench.ts new file mode 100644 index 0000000000..76e8a02a1c --- /dev/null +++ b/packages/angular-table/tests/flex-render/flex-render.bench.ts @@ -0,0 +1,239 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { bench, describe } from 'vitest' +import { + FlexRender, + flexRenderComponent, + injectTable, + stockFeatures, +} from '../../src' +import type { ColumnDef } from '../../src' + +const benchmarkOptions = { time: 2_000, warmupTime: 500 } + +@Component({ + template: ` + {{ tick() }} + @for (item of items; track item) { + + {{ value }} + + } + `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class PrimitiveTable { + readonly items = Array.from({ length: 500 }, (_, index) => index) + readonly value = signal('value') + readonly tick = signal(0) + readonly context = {} + readonly render = () => this.value() +} + +@Component({ + template: ``, +}) +class RenderedComponent {} + +describe('flexRender hot paths', () => { + const fixture = TestBed.createComponent(PrimitiveTable) + fixture.detectChanges() + + bench( + 'unrelated change detection for 500 primitive cells', + () => { + fixture.componentInstance.tick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'create 500 component render descriptors', + () => { + for (let index = 0; index < 500; index++) { + flexRenderComponent(RenderedComponent) + } + }, + benchmarkOptions, + ) +}) + +interface BenchmarkRow { + id: string + values: Array +} + +const rowCount = 100 +const columnCount = 12 +const largeTableData: Array = Array.from( + { length: rowCount }, + (_, rowIndex) => ({ + id: `row-${rowIndex}`, + values: Array.from( + { length: columnCount }, + (_, columnIndex) => `${rowIndex}:${columnIndex}`, + ), + }), +) +const handleActivate = () => {} + +@Component({ + selector: 'benchmark-cell-a', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellA { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + selector: 'benchmark-cell-b', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class BenchmarkCellB { + readonly value = input.required() + readonly version = input.required() + readonly activate = output() +} + +@Component({ + template: ` + {{ hostTick() }} + + + @for (row of table.getRowModel().rows; track row.id) { + + @for (cell of row.getVisibleCells(); track cell.id) { + + } + + } + +
+ + {{ value }} + +
+ `, + imports: [FlexRender], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class LargeMixedTable { + readonly hostTick = signal(0) + readonly valueVersion = signal(0) + readonly componentKind = signal<'a' | 'b'>('a') + readonly contentKind = signal<'primitive' | 'component'>('primitive') + + readonly columns: Array> = + Array.from({ length: columnCount }, (_, columnIndex) => ({ + id: `column-${columnIndex}`, + accessorFn: (row) => row.values[columnIndex]!, + cell: (context) => { + const value = context.getValue() + + // Four primitive columns whose values change in place. + if (columnIndex < 4) { + return `${value}:${this.valueVersion()}` + } + + // Four stable component columns whose inputs change frequently. + if (columnIndex < 8) { + const component = + columnIndex % 2 === 0 ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: this.valueVersion() }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that intentionally replace component A with component B. + if (columnIndex < 10) { + const component = + this.componentKind() === 'a' ? BenchmarkCellA : BenchmarkCellB + return flexRenderComponent(component, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + } + + // Two columns that cross the primitive/component view boundary. + return this.contentKind() === 'primitive' + ? value + : flexRenderComponent(BenchmarkCellA, { + inputs: { value, version: 0 }, + outputs: { activate: handleActivate }, + }) + }, + })) + + readonly table = injectTable(() => ({ + data: largeTableData, + columns: this.columns, + features: stockFeatures, + getRowId: (row) => row.id, + })) +} + +describe('flexRender large mixed table', () => { + const fixture = TestBed.createComponent(LargeMixedTable) + fixture.detectChanges() + + const instance = fixture.componentInstance + const renderedCellCount = fixture.nativeElement.querySelectorAll('td').length + if (renderedCellCount !== rowCount * columnCount) { + throw new Error(`Expected 1,200 cells, rendered ${renderedCellCount}`) + } + + bench( + 'unrelated host change with 1,200 mounted cells', + () => { + instance.hostTick.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'update 400 primitives and 400 stable component inputs', + () => { + instance.valueVersion.update((value) => value + 1) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'replace 200 component A/B cell views', + () => { + instance.componentKind.update((value) => (value === 'a' ? 'b' : 'a')) + fixture.detectChanges() + }, + benchmarkOptions, + ) + + bench( + 'switch 200 cells between primitive and component views', + () => { + instance.contentKind.update((value) => + value === 'primitive' ? 'component' : 'primitive', + ) + fixture.detectChanges() + }, + benchmarkOptions, + ) +}) diff --git a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts index 34c1c07797..e92ca5638c 100644 --- a/packages/angular-table/tests/flex-render/flex-render.unit.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts @@ -1,19 +1,15 @@ -import { - Component, - input, - signal, - ViewChild, - type TemplateRef, -} from '@angular/core' -import { TestBed, type ComponentFixture } from '@angular/core/testing' -import { describe, expect, test } from 'vitest' +import { Component, ViewChild, input, output, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, test, vi } from 'vitest' import { FlexRender, - flexRenderComponent, FlexRenderDirective, + flexRenderComponent, injectFlexRenderContext, } from '../../src' import { setFixtureSignalInput, setFixtureSignalInputs } from '../test-utils' +import type { ComponentFixture } from '@angular/core/testing' +import type { TemplateRef } from '@angular/core' describe('FlexRenderDirective', () => { test('should render primitives', () => { @@ -62,6 +58,123 @@ describe('FlexRenderDirective', () => { expect((fixture.nativeElement as HTMLElement).matches(':empty')).toBe(true) }) + test('should evaluate and update primitive content only when its dependencies change', () => { + const value = signal('Initial value') + const render = vi.fn(() => value()) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: render, + context: {}, + }) + + const initialSpan = fixture.nativeElement.querySelector('span') + expect(render).toHaveBeenCalledTimes(1) + expect(initialSpan.textContent).toEqual('Initial value') + + fixture.detectChanges() + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + + value.set('Updated value') + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('span')).toBe(initialSpan) + expect(initialSpan.textContent).toEqual('Updated value') + }) + + test('should memoize resolved content across input and internal signal updates', () => { + const value = signal('first') + const render = vi.fn( + (context: Record) => `${context['label']}:${value()}`, + ) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: render, + context: { label: 'initial' }, + }) + + expect(render).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('initial:first') + + setFixtureSignalInput(fixture, 'context', { label: 'updated' }) + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.textContent).toEqual('updated:first') + + value.set('second') + fixture.detectChanges() + + expect(render).toHaveBeenCalledTimes(3) + expect(fixture.nativeElement.textContent).toEqual('updated:second') + }) + + test('should replace render-function effects when the content input changes', () => { + const firstValue = signal('first') + const secondValue = signal('second') + const firstRender = vi.fn(() => firstValue()) + const secondRender = vi.fn(() => secondValue()) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: firstRender, + context: {}, + }) + + expect(firstRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('first') + + setFixtureSignalInput(fixture, 'content', secondRender) + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('second') + + firstValue.set('stale first') + fixture.detectChanges() + + expect(firstRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('second') + + setFixtureSignalInput(fixture, 'content', 'static') + fixture.detectChanges() + secondValue.set('stale second') + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect(fixture.nativeElement.textContent).toEqual('static') + + setFixtureSignalInput(fixture, 'content', firstRender) + fixture.detectChanges() + firstValue.set('live first') + fixture.detectChanges() + + expect(firstRender).toHaveBeenCalledTimes(3) + expect(fixture.nativeElement.textContent).toEqual('live first') + }) + + test('should react when a render function changes from null to content', () => { + const visible = signal(false) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => (visible() ? 'Visible' : null), + context: {}, + }) + + expect((fixture.nativeElement as HTMLElement).matches(':empty')).toBe(true) + + visible.set(true) + fixture.detectChanges() + + expectPrimitiveValueIs(fixture, 'Visible') + }) + test('should render TemplateRef', () => { @Component({ template: ` @@ -122,6 +235,229 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('Updated value') }) + test('should release and restore component output subscriptions', () => { + @Component({ + template: ``, + standalone: true, + }) + class FakeComponent { + readonly changed = output() + } + + const enabled = signal(true) + const listener = vi.fn() + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + outputs: enabled() ? { changed: listener } : {}, + }), + context: {}, + }) + + const button = fixture.nativeElement.querySelector( + 'button', + ) as HTMLButtonElement + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(false) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(1) + + enabled.set(true) + fixture.detectChanges() + button.click() + expect(listener).toHaveBeenCalledTimes(2) + expect(fixture.nativeElement.querySelector('button')).toBe(button) + }) + + test('should set component inputs by property name when they have an alias', () => { + @Component({ + template: `{{ value() }}`, + standalone: true, + }) + class FakeComponent { + readonly value = input('', { alias: 'aliasedValue' }) + } + + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + inputs: { value: 'Aliased input value' }, + }), + context: {}, + }) + + expect(fixture.nativeElement.textContent).toEqual('Aliased input value') + }) + + test('should subscribe to aliased outputs by property name', () => { + @Component({ + template: ``, + standalone: true, + }) + class FakeComponent { + readonly changed = output({ alias: 'aliasedChanged' }) + } + + const listener = vi.fn() + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(FakeComponent, { + outputs: { changed: listener }, + }), + context: {}, + }) + + fixture.nativeElement.querySelector('button').click() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + test('should preserve omitted inputs and forward explicit undefined', () => { + @Component({ + selector: 'app-patched-input-component', + template: `{{ value() === undefined ? 'undefined' : value() }}`, + standalone: true, + }) + class FakeComponent { + readonly value = input('initial') + } + + const mode = signal<'set' | 'omit' | 'clear'>('set') + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => { + const currentMode = mode() + const inputs: { value?: string | undefined } = + currentMode === 'set' + ? { value: 'updated' } + : currentMode === 'clear' + ? { value: undefined } + : {} + return flexRenderComponent(FakeComponent, { inputs }) + }, + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-patched-input-component', + ) + expect(initialHost.textContent).toEqual('updated') + + mode.set('omit') + fixture.detectChanges() + + expect( + fixture.nativeElement.querySelector('app-patched-input-component'), + ).toBe(initialHost) + expect(initialHost.textContent).toEqual('updated') + + mode.set('clear') + fixture.detectChanges() + + expect(initialHost.textContent).toEqual('undefined') + }) + + test('should reuse a component by type and key and recreate it when the key changes', () => { + @Component({ + selector: 'app-keyed-component', + template: `{{ value() }}`, + standalone: true, + }) + class KeyedComponent { + readonly value = input.required() + } + + const key = signal('first') + const value = signal('Initial value') + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: () => + flexRenderComponent(KeyedComponent, { + key: key(), + inputs: { value: value() }, + // These creation-time arrays are intentionally recreated whenever + // the render function runs. They do not affect reuse without a new key. + bindings: [], + directives: [], + }), + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-keyed-component', + ) + expect(initialHost.textContent).toEqual('Initial value') + + value.set('Updated value') + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).toBe( + initialHost, + ) + expect(initialHost.textContent).toEqual('Updated value') + + key.set(2) + fixture.detectChanges() + + expect(fixture.nativeElement.querySelector('app-keyed-component')).not.toBe( + initialHost, + ) + expect(fixture.nativeElement.textContent).toEqual('Updated value') + }) + + test('should recreate content when the content function reference changes', () => { + @Component({ + selector: 'app-reusable-component', + template: `{{ value() }}`, + standalone: true, + }) + class ReusableComponent { + readonly value = input.required() + } + + const firstRender = vi.fn(() => + flexRenderComponent(ReusableComponent, { + key: 'stable', + inputs: { value: 'first' }, + }), + ) + const secondRender = vi.fn(() => + flexRenderComponent(ReusableComponent, { + key: 'stable', + inputs: { value: 'second' }, + }), + ) + const fixture = TestBed.createComponent(TestRenderComponent) + + setFixtureSignalInputs(fixture, { + content: firstRender, + context: {}, + }) + + const initialHost = fixture.nativeElement.querySelector( + 'app-reusable-component', + ) + expect(firstRender).toHaveBeenCalledTimes(1) + expect(initialHost.textContent).toEqual('first') + + setFixtureSignalInput(fixture, 'content', secondRender) + fixture.detectChanges() + + expect(secondRender).toHaveBeenCalledTimes(1) + expect( + fixture.nativeElement.querySelector('app-reusable-component'), + ).not.toBe(initialHost) + expect(fixture.nativeElement.textContent).toEqual('second') + }) + test('should rerender when content has conditional return with different component types', () => { @Component({ selector: 'app-fake-a', @@ -160,8 +496,6 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('B component') }) - // Skip for now, test framework (using ComponentRef.setInput) cannot recognize signal inputs - // as component inputs test('should render custom components', async () => { @Component({ template: `{{ row().property }}`, @@ -193,6 +527,36 @@ describe('FlexRenderDirective', () => { expect(fixture.nativeElement.textContent).toEqual('Updated value') }) + + test('should ignore context properties that are not component inputs', () => { + @Component({ + template: `{{ row() }}`, + standalone: true, + }) + class FakeComponent { + readonly row = input.required() + } + + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + + try { + const fixture = TestBed.createComponent(TestRenderComponent) + setFixtureSignalInputs(fixture, { + content: () => FakeComponent, + context: { + row: 'Known input', + unknownContextProperty: 'Ignored value', + }, + }) + + expect(fixture.nativeElement.textContent).toEqual('Known input') + expect(consoleError).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) }) @Component({ diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index e8804b5b5b..f3e78df489 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -17,6 +17,21 @@ import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { + test('evaluates options once while constructing the table', () => { + const options = vi.fn(() => ({ + data: [], + features: stockFeatures, + columns: [], + })) + const table = TestBed.runInInjectionContext(() => injectTable(options)) + + expect(options).not.toHaveBeenCalled() + + void table.options + + expect(options).toHaveBeenCalledTimes(1) + }) + test('should support required signal inputs', async () => { type Data = { id: string; title: string } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99d353cab2..c2476c732d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,6 +747,31 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/alpine/realtime-trading: + dependencies: + '@tanstack/alpine-table': + specifier: workspace:* + version: link:../../../packages/alpine-table + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.1 + '@tanstack/virtual-core': + specifier: ^3.13.35 + version: 3.17.7 + alpinejs: + specifier: ^3.15.12 + version: 3.16.1 + devDependencies: + '@types/alpinejs': + specifier: ^3.13.11 + version: 3.13.11 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/alpine/row-pinning: dependencies: '@faker-js/faker': @@ -907,10 +932,10 @@ importers: devDependencies: '@angular/build': specifier: ^22.1.2 - version: 22.1.2(273b611b619075aa5982f3d91848666a) + version: 22.1.2(65f1be7885817eb3e9a9fc8dff31d1cf) '@angular/cli': specifier: ^22.1.2 - version: 22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@8.1.1) + version: 22.1.2(@types/node@26.2.0)(chokidar@5.0.0)(supports-color@8.1.1) '@angular/compiler-cli': specifier: ^22.1.1 version: 22.1.1(@angular/compiler@22.1.1)(typescript@6.0.3) @@ -2266,6 +2291,46 @@ importers: specifier: 6.0.3 version: 6.0.3 + examples/angular/realtime-trading: + dependencies: + '@angular/common': + specifier: ^22.1.0 + version: 22.1.1(@angular/core@22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^22.1.0 + version: 22.1.1 + '@angular/core': + specifier: ^22.1.0 + version: 22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2) + '@angular/platform-browser': + specifier: ^22.1.0 + version: 22.1.1(@angular/animations@22.1.1(@angular/core@22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2)))(@angular/common@22.1.1(@angular/core@22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2)) + '@tanstack/angular-table': + specifier: workspace:* + version: link:../../../packages/angular-table + '@tanstack/angular-virtual': + specifier: ^6.0.2 + version: 6.0.2(@angular/core@22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2)) + rxjs: + specifier: ~7.8.2 + version: 7.8.2 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@angular/build': + specifier: ^22.1.2 + version: 22.1.2(0b302439204a1b184ed0f1b31fd67e96) + '@angular/cli': + specifier: ^22.1.2 + version: 22.1.2(@types/node@26.2.0)(chokidar@5.0.0)(supports-color@8.1.1) + '@angular/compiler-cli': + specifier: ^22.1.0 + version: 22.1.1(@angular/compiler@22.1.1)(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + examples/angular/remote-data: dependencies: '@angular/common': @@ -4829,6 +4894,82 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/ember/realtime-trading: + dependencies: + '@embroider/macros': + specifier: 1.20.5 + version: 1.20.5(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/router': + specifier: 3.0.6 + version: 3.0.6(@embroider/core@4.6.2(@glint/template@1.7.10)(supports-color@8.1.1))(supports-color@8.1.1) + '@glimmer/component': + specifier: 2.1.1 + version: 2.1.1(supports-color@8.1.1) + '@tanstack/ember-table': + specifier: workspace:* + version: link:../../../packages/ember-table + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.1 + '@tanstack/virtual-core': + specifier: ^3.13.36 + version: 3.17.7 + decorator-transforms: + specifier: 2.4.0 + version: 2.4.0(@babel/core@7.29.7(supports-color@8.1.1)) + ember-modifier: + specifier: ^4.3.0 + version: 4.3.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + ember-source: + specifier: 7.1.0 + version: 7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1) + ember-strict-application-resolver: + specifier: 0.1.1 + version: 0.1.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + devDependencies: + '@babel/core': + specifier: 7.29.7 + version: 7.29.7(supports-color@8.1.1) + '@babel/plugin-transform-runtime': + specifier: 7.29.7 + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/plugin-transform-typescript': + specifier: 7.29.7 + version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@babel/runtime': + specifier: 7.29.7 + version: 7.29.7 + '@ember/app-tsconfig': + specifier: 2.0.0 + version: 2.0.0 + '@embroider/core': + specifier: 4.6.2 + version: 4.6.2(@glint/template@1.7.10)(supports-color@8.1.1) + '@glint/ember-tsc': + specifier: 1.8.14 + version: 1.8.14(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) + '@glint/template': + specifier: 1.7.10 + version: 1.7.10 + '@glint/tsserver-plugin': + specifier: 2.5.20 + version: 2.5.20(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1) + '@nullvoxpopuli/ember-vite': + specifier: 1.1.0 + version: 1.1.0(@glint/template@1.7.10)(@types/babel__core@7.20.5)(rollup@4.62.4)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + '@rollup/plugin-babel': + specifier: 7.1.0 + version: 7.1.0(@babel/core@7.29.7(supports-color@8.1.1))(@types/babel__core@7.20.5)(rollup@4.62.4)(supports-color@8.1.1) + babel-plugin-ember-template-compilation: + specifier: 4.0.0 + version: 4.0.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/ember/remote-data: dependencies: '@embroider/macros': @@ -5886,6 +6027,28 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/lit/realtime-trading: + dependencies: + '@tanstack/lit-table': + specifier: workspace:* + version: link:../../../packages/lit-table + '@tanstack/lit-virtual': + specifier: ^3.13.36 + version: 3.13.36(lit@3.3.3) + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.1 + lit: + specifier: ^3.3.3 + version: 3.3.3 + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/lit/row-pinning: dependencies: '@faker-js/faker': @@ -6708,6 +6871,31 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/octane/realtime-trading: + dependencies: + '@tanstack/octane-table': + specifier: workspace:* + version: link:../../../packages/octane-table + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.1 + '@tanstack/virtual-core': + specifier: ^3.13.36 + version: 3.17.7 + octane: + specifier: 0.1.21 + version: 0.1.21(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + devDependencies: + '@tsrx/typescript-plugin': + specifier: ^0.3.118 + version: 0.3.118(octane@0.1.21(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/octane/row-pinning: dependencies: '@faker-js/faker': @@ -7533,6 +7721,31 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/preact/realtime-trading: + dependencies: + '@tanstack/preact-store': + specifier: ^0.13.1 + version: 0.13.2(preact@10.29.8) + '@tanstack/preact-table': + specifier: workspace:* + version: link:../../../packages/preact-table + '@tanstack/virtual-core': + specifier: ^3.17.7 + version: 3.17.7 + preact: + specifier: ^10.29.7 + version: 10.29.8(preact-render-to-string@6.7.0) + devDependencies: + '@preact/preset-vite': + specifier: ^2.10.6 + version: 2.10.6(@babel/core@7.29.7(supports-color@8.1.1))(preact@10.29.8)(rollup@4.62.4)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/preact/row-pinning: dependencies: '@faker-js/faker': @@ -10229,6 +10442,49 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/react/realtime-trading: + dependencies: + '@tanstack/react-store': + specifier: ^0.11.0 + version: 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-table': + specifier: workspace:* + version: link:../../../packages/react-table + '@tanstack/react-virtual': + specifier: ^3.14.9 + version: 3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + devDependencies: + '@rolldown/plugin-babel': + specifier: ^0.2.3 + version: 0.2.3(@babel/core@8.0.1)(@babel/plugin-transform-runtime@8.0.1(@babel/core@8.0.1))(@babel/runtime@8.0.0)(rolldown@1.2.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + '@rollup/plugin-replace': + specifier: ^6.0.3 + version: 6.0.3(rollup@4.62.4) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/plugin-transform-runtime@8.0.1(@babel/core@8.0.1))(@babel/runtime@8.0.0)(rolldown@1.2.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/react/row-dnd: dependencies: '@dnd-kit/core': @@ -11612,6 +11868,28 @@ importers: specifier: ^2.11.14 version: 2.11.14(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10))(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + examples/solid/realtime-trading: + dependencies: + '@tanstack/solid-table': + specifier: workspace:* + version: link:../../../packages/solid-table + '@tanstack/solid-virtual': + specifier: ^3.13.36 + version: 3.13.36(solid-js@1.9.14) + solid-js: + specifier: ^1.9.14 + version: 1.9.14 + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + vite-plugin-solid: + specifier: ^2.11.14 + version: 2.11.14(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10))(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + examples/solid/row-pinning: dependencies: '@tanstack/solid-table': @@ -12923,6 +13201,40 @@ importers: specifier: ^8.2.1 version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/svelte/realtime-trading: + dependencies: + '@tanstack/store': + specifier: ^0.11.0 + version: 0.11.1 + '@tanstack/svelte-store': + specifier: ^0.12.0 + version: 0.12.1(svelte@5.56.8(@typescript-eslint/types@8.67.0)) + '@tanstack/svelte-table': + specifier: workspace:* + version: link:../../../packages/svelte-table + '@tanstack/svelte-virtual': + specifier: ^3.13.35 + version: 3.13.35(svelte@5.56.8(@typescript-eslint/types@8.67.0)) + svelte: + specifier: ^5.56.8 + version: 5.56.8(@typescript-eslint/types@8.67.0) + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^7.2.0 + version: 7.3.0(svelte@5.56.8(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + '@tsconfig/svelte': + specifier: ^5.0.8 + version: 5.0.8 + svelte-check: + specifier: ^4.7.4 + version: 4.7.4(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.67.0))(typescript@6.0.3) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + examples/svelte/row-pinning: devDependencies: '@faker-js/faker': @@ -14389,6 +14701,37 @@ importers: specifier: ^3.3.9 version: 3.3.9(typescript@6.0.3) + examples/vue/realtime-trading: + dependencies: + '@tanstack/vue-table': + specifier: workspace:* + version: link:../../../packages/vue-table + '@tanstack/vue-virtual': + specifier: ^3.13.35 + version: 3.13.35(vue@3.5.41(typescript@6.0.3)) + vue: + specifier: ^3.5.40 + version: 3.5.41(typescript@6.0.3) + devDependencies: + '@types/node': + specifier: ^26.1.2 + version: 26.2.0 + '@vitejs/plugin-vue': + specifier: ^6.0.8 + version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + '@vitejs/plugin-vue-jsx': + specifier: ^5.1.6 + version: 5.1.6(supports-color@7.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.0 + version: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + vue-tsc: + specifier: ^3.3.5 + version: 3.3.9(typescript@6.0.3) + examples/vue/row-pinning: dependencies: '@faker-js/faker': @@ -16881,6 +17224,10 @@ packages: resolution: {integrity: sha512-GYbaiC1v9inbiwVg5s+Sd14Jc66NYxg23mEOocgWAZFCtOfhMnRLaLAA6SytW76myVVYImGHX5PFK4PVuH2yng==} engines: {node: 12.* || 14.* || >= 16} + '@embroider/core@4.6.2': + resolution: {integrity: sha512-YvUe/ltNCmfDWaPLAjbUJc97sJSyACejR5FXHxEKkzSykUCx/YRt8l7izViSryNyAyEzU3YBbD0WoPIn9IeqGA==} + engines: {node: '>= 20.19.*'} + '@embroider/core@4.6.3': resolution: {integrity: sha512-BdUDoifoB6YDJz30H+GNvQcEAB6a8/3OQinS+5wP3LlTCfPQjHChtZxdMwqvzReOSU1qVi2WGDx4xJh/0OREjg==} engines: {node: '>= 20.19.*'} @@ -17370,9 +17717,25 @@ packages: ember-source: optional: true + '@glint/ember-tsc@1.8.14': + resolution: {integrity: sha512-m9Tq3LJRR4LbQhqkgZQG3m36NAUaqZ/179yU83i05JATEvuwB54d8P/+Mj1CADrZC3Yh+XTmEA4JetCvo8OkLg==} + hasBin: true + peerDependencies: + ember-source: '>=3.28.0' + typescript: '>=5.6.0' + peerDependenciesMeta: + ember-source: + optional: true + + '@glint/template@1.7.10': + resolution: {integrity: sha512-dixKlQDF4jMM7AekQ+PPdMZRJy+Jze6ejcV/wUzRJm59nFY+D32zsYxGIm3vVLiIvafhW78QSgAf3KPUrrP5Jg==} + '@glint/template@1.8.0': resolution: {integrity: sha512-TeVv3JnM0wbF86Sl2YyNhxeTEPnR5Q0xtIKqZueVzX8ouL3bj49Rk1gS0jFq7FNL7u6R+1oqToZBQmI+OCL4QA==} + '@glint/tsserver-plugin@2.5.20': + resolution: {integrity: sha512-ivNb1HPzS3lQjrcmmRghncApqGj5jsKwqsXRLxtOm21v6xLOY7X3ix21rXu8KxhVQMo1FoIZarwtA12lGWGWqg==} + '@glint/tsserver-plugin@2.7.0': resolution: {integrity: sha512-sID/TfqvIZL6Igf3zrBeSQ32Y1xlSLiBqX6GkNNeiu5nxUHSxrala0zzDT/vgLm8E/BcvqcfzX6E1L040N+iWg==} @@ -18142,6 +18505,7 @@ packages: '@ngtools/webpack@22.1.2': resolution: {integrity: sha512-5o3zbOdEHv5+wjRCMN+t6RBou28dbPkio5/rxEJBsQ9iEq11K+atsSKEaZXBMUhYr9nLYR8Pckm5Cqx2FP9R2Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + deprecated: Angular's Webpack support is deprecated. Use the esbuild and Vite-based "@angular/build" package instead. peerDependencies: '@angular/compiler-cli': ^22.0.0 typescript: '>=6.0 <6.1' @@ -21070,11 +21434,6 @@ packages: peerDependencies: preact: ^10.0.0 - '@tanstack/preact-store@0.13.1': - resolution: {integrity: sha512-0h8ku2LfJ/TtVtgx24CLgS2OgzY5wVsRWIGktE0yq6b6fHlsCcY55IEJXTDRs0RTYWFOXu7N/1kfb2SLEOasaw==} - peerDependencies: - preact: ^10.0.0 - '@tanstack/preact-store@0.13.2': resolution: {integrity: sha512-CBosOLog5UnaZX9YHRA2LKJm5XR0tCABvNWDfDMcyFeLtEnJ9W9DOVbz4JxXMb3+LZhGEMVrt1iA77npNx/9rw==} peerDependencies: @@ -21219,12 +21578,6 @@ packages: vite: optional: true - '@tanstack/react-store@0.11.0': - resolution: {integrity: sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.11.1': resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} peerDependencies: @@ -21432,11 +21785,6 @@ packages: peerDependencies: svelte: ^5.25.0 - '@tanstack/svelte-store@0.12.0': - resolution: {integrity: sha512-XhXlU3jIO/WxikfeVczRdsAvRWzsLBh8Ic6sC7nzfzvMbPut7ZSdCbE7/usfm0bMjVGMmZmyzZy2xRu73QYWAA==} - peerDependencies: - svelte: ^5.0.0 - '@tanstack/svelte-store@0.12.1': resolution: {integrity: sha512-ZFj8gyIWHZRYLv8RHp5jhnMhvw15g86KinotrUp9RfY7ygjkU3CcaMUytIdMJ4Xzq/9GqVlAphV9gFIGKRXWSw==} peerDependencies: @@ -21707,9 +22055,6 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -23974,6 +24319,9 @@ packages: ember-estree@0.6.10: resolution: {integrity: sha512-5nItZGzvxHgoCdASQbn9qBzF4sdjSggUeZpXdihAanmYWm6XkObT/TlUvakYtoKret1/8gm0jNLwNPqZYwHtCw==} + ember-modifier@4.3.0: + resolution: {integrity: sha512-O0rirSLQbGg0VJ/NqoQ4uN1bh2iAekZC/Ykma+FkjCM2ofrO38u+d8n3+AK6uVWeMJmogGX2KL+Is5fofoInJg==} + ember-qunit@9.1.0: resolution: {integrity: sha512-dE32lSODyv4em0ACBS8PDzrAIPKR75hhSpn/au0qFBlzbdmNA/VVsgv2aoFAVRdlQ0zYJ8EiNPFqsBFciIeXVA==} peerDependencies: @@ -23987,6 +24335,12 @@ packages: resolution: {integrity: sha512-89oVHVJwmLDvGvAUWgS87KpBoRhy3aZ6U0Ql6HOmU4TrPkyaa8pM0W81wj9cIwjYprcQtN9EwzZMHnq46+oUyw==} engines: {node: 8.* || 10.* || >= 12} + ember-source@7.1.0: + resolution: {integrity: sha512-qOHhTiVMeYcNp2UQKuAqsf33LKgNtzqvw46dQX5AqutTfXXPn4KYR0+aiGQdJaCuzs81nFvvTDJuHzELOZ5UBg==} + engines: {node: '>= 20.19'} + peerDependencies: + '@glimmer/component': '>= 1.1.2' + ember-source@7.2.0: resolution: {integrity: sha512-pWjYFeM76vAgiFvqrBacAsQh94vTIy8SC1X2S3goULJHJ5/tVWAZ2NtaLFx9oZd3/Lk0gGRdsCMla9rQY58vyw==} engines: {node: '>= 20.19'} @@ -25338,6 +25692,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -26200,6 +26558,21 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + octane@0.1.21: + resolution: {integrity: sha512-cfemarQcdslN3WG9f/e1EzcwO2JvbJQLliLp3fF6ECiel28vT5ncaigSbgGQZgMWYX/1CWM5ChlB5xoqOVn72Q==} + engines: {node: '>=22'} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + vite: ^8.0.16 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + vite: + optional: true + octane@0.1.36: resolution: {integrity: sha512-egscdNROPtLh0Kd4TijYXr3/rVdjyDVCr7r4/mArAAjAuvqhRVsYcLDGr88MT4JSvZwDtISsOALNZY2crLETjg==} engines: {node: '>=22.22.2'} @@ -29099,9 +29472,8 @@ snapshots: - terser - tsx - yaml - optional: true - '@angular/build@22.1.2(273b611b619075aa5982f3d91848666a)': + '@angular/build@22.1.2(600b68b74c5a5d3d3c143d1ae1ff9706)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) @@ -29110,8 +29482,8 @@ snapshots: '@babel/core': 8.0.1 '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 - '@inquirer/confirm': 6.1.1(@types/node@26.1.2) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + '@inquirer/confirm': 6.1.1(@types/node@26.2.0) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0)) beasties: 0.4.3 browserslist: 4.28.2 esbuild: 0.28.1 @@ -29131,7 +29503,7 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: '@angular/core': 22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2) @@ -29142,10 +29514,10 @@ snapshots: less: 4.6.7 lmdb: 3.5.6 ng-packagr: 22.1.1(@angular/compiler-cli@22.1.1(@angular/compiler@22.1.1)(typescript@6.0.3))(tailwindcss@4.3.3)(tslib@2.8.1)(typescript@6.0.3) - postcss: 8.5.25 + postcss: 8.5.19 rollup: 4.62.4 tailwindcss: 4.3.3 - vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -29160,7 +29532,7 @@ snapshots: - tsx - yaml - '@angular/build@22.1.2(600b68b74c5a5d3d3c143d1ae1ff9706)': + '@angular/build@22.1.2(65f1be7885817eb3e9a9fc8dff31d1cf)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) @@ -29170,7 +29542,7 @@ snapshots: '@babel/helper-annotate-as-pure': 8.0.0 '@babel/helper-split-export-declaration': 7.24.7 '@inquirer/confirm': 6.1.1(@types/node@26.2.0) - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) beasties: 0.4.3 browserslist: 4.28.2 esbuild: 0.28.1 @@ -29190,7 +29562,7 @@ snapshots: tinyglobby: 0.2.17 tslib: 2.8.1 typescript: 6.0.3 - vite: 8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0) + vite: 8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0) watchpack: 2.5.2 optionalDependencies: '@angular/core': 22.1.1(@angular/compiler@22.1.1)(rxjs@7.8.2) @@ -29201,10 +29573,10 @@ snapshots: less: 4.6.7 lmdb: 3.5.6 ng-packagr: 22.1.1(@angular/compiler-cli@22.1.1(@angular/compiler@22.1.1)(typescript@6.0.3))(tailwindcss@4.3.3)(tslib@2.8.1)(typescript@6.0.3) - postcss: 8.5.19 + postcss: 8.5.25 rollup: 4.62.4 tailwindcss: 4.3.3 - vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.19))(terser@5.49.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1(@noble/hashes@2.2.0))(vite@8.1.5(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.25))(terser@5.49.0)(yaml@2.9.0)) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -29347,28 +29719,6 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@angular/cli@22.1.2(@types/node@26.1.2)(chokidar@5.0.0)(supports-color@8.1.1)': - dependencies: - '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) - '@angular-devkit/core': 22.1.2(chokidar@5.0.0) - '@angular-devkit/schematics': 22.1.2(chokidar@5.0.0) - '@inquirer/prompts': 8.5.2(@types/node@26.1.2) - '@listr2/prompt-adapter-inquirer': 4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2) - '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.4.3) - '@schematics/angular': 22.1.2(chokidar@5.0.0) - jsonc-parser: 3.3.1 - listr2: 10.2.2 - npm-package-arg: 14.0.0 - parse5-html-rewriting-stream: 8.0.1 - semver: 7.8.5 - yargs: 18.0.0 - zod: 4.4.3 - transitivePeerDependencies: - - '@cfworker/json-schema' - - '@types/node' - - chokidar - - supports-color - '@angular/cli@22.1.2(@types/node@26.2.0)(chokidar@5.0.0)(supports-color@8.1.1)': dependencies: '@angular-devkit/architect': 0.2201.2(chokidar@5.0.0) @@ -31420,6 +31770,78 @@ snapshots: transitivePeerDependencies: - supports-color + '@embroider/core@4.6.2(@glint/template@1.7.10)(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@embroider/macros': 1.20.5(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/reverse-exports': 0.2.0 + '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) + assert-never: 1.4.0 + babel-plugin-ember-template-compilation: 3.1.0 + broccoli-node-api: 1.7.0 + broccoli-persistent-filter: 3.1.3(supports-color@8.1.1) + broccoli-plugin: 4.0.7(supports-color@8.1.1) + broccoli-source: 3.0.1 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + fast-sourcemap-concat: 2.1.1(supports-color@8.1.1) + fs-extra: 9.1.0 + fs-tree-diff: 2.0.1(supports-color@8.1.1) + handlebars: 4.7.9 + js-string-escape: 1.0.1 + jsdom: 25.0.1(supports-color@8.1.1) + lodash: 4.18.1 + resolve: 1.22.12 + resolve-package-path: 4.0.3 + resolve.exports: 2.0.3 + semver: 7.8.5 + typescript-memoize: 1.1.1 + walk-sync: 3.0.0 + transitivePeerDependencies: + - '@glint/template' + - bufferutil + - canvas + - supports-color + - utf-8-validate + + '@embroider/core@4.6.3(@glint/template@1.7.10)(supports-color@8.1.1)': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@babel/parser': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@8.1.1) + '@embroider/macros': 1.20.6(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/reverse-exports': 0.2.0 + '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) + assert-never: 1.4.0 + babel-plugin-ember-template-compilation: 3.1.0 + broccoli-node-api: 1.7.0 + broccoli-persistent-filter: 3.1.3(supports-color@8.1.1) + broccoli-plugin: 4.0.7(supports-color@8.1.1) + broccoli-source: 3.0.1 + debug: 4.4.3(supports-color@8.1.1) + escape-string-regexp: 4.0.0 + fast-sourcemap-concat: 2.1.1(supports-color@8.1.1) + fs-extra: 9.1.0 + fs-tree-diff: 2.0.1(supports-color@8.1.1) + handlebars: 4.7.9 + js-string-escape: 1.0.1 + jsdom: 25.0.1(supports-color@8.1.1) + lodash: 4.18.1 + resolve: 1.22.12 + resolve-package-path: 4.0.3 + resolve.exports: 2.0.3 + semver: 7.8.5 + typescript-memoize: 1.1.1 + walk-sync: 3.0.0 + transitivePeerDependencies: + - '@glint/template' + - bufferutil + - canvas + - supports-color + - utf-8-validate + '@embroider/core@4.6.3(@glint/template@1.8.0)(supports-color@8.1.1)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -31456,6 +31878,22 @@ snapshots: - supports-color - utf-8-validate + '@embroider/macros@1.20.5(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1)': + dependencies: + '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) + assert-never: 1.4.0 + babel-import-util: 3.0.1 + ember-cli-babel: 8.3.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + find-up: 5.0.0 + lodash: 4.18.1 + resolve: 1.22.12 + semver: 7.8.5 + optionalDependencies: + '@glint/template': 1.7.10 + transitivePeerDependencies: + - '@babel/core' + - supports-color + '@embroider/macros@1.20.5(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.8.0)(supports-color@8.1.1)': dependencies: '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) @@ -31472,6 +31910,22 @@ snapshots: - '@babel/core' - supports-color + '@embroider/macros@1.20.6(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1)': + dependencies: + '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) + assert-never: 1.4.0 + babel-import-util: 3.0.1 + ember-cli-babel: 8.3.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + find-up: 5.0.0 + lodash: 4.18.1 + resolve: 1.22.12 + semver: 7.8.5 + optionalDependencies: + '@glint/template': 1.7.10 + transitivePeerDependencies: + - '@babel/core' + - supports-color + '@embroider/macros@1.20.6(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.8.0)(supports-color@8.1.1)': dependencies: '@embroider/shared-internals': 3.1.1(supports-color@8.1.1) @@ -31493,6 +31947,15 @@ snapshots: mem: 8.1.1 resolve.exports: 2.0.3 + '@embroider/router@3.0.6(@embroider/core@4.6.2(@glint/template@1.7.10)(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@ember/test-waiters': 4.1.2(supports-color@8.1.1) + '@embroider/addon-shim': 1.10.3(supports-color@8.1.1) + optionalDependencies: + '@embroider/core': 4.6.2(@glint/template@1.7.10)(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@embroider/router@3.0.6(@embroider/core@4.6.3(@glint/template@1.8.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@ember/test-waiters': 4.1.2(supports-color@8.1.1) @@ -31520,6 +31983,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@embroider/vite@1.7.8(@embroider/core@4.6.3(@glint/template@1.7.10)(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@embroider/core': 4.6.3(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/macros': 1.20.5(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/reverse-exports': 0.2.0 + assert-never: 1.4.0 + browserslist: 4.28.7 + browserslist-to-esbuild: 2.1.1(browserslist@4.28.7) + chalk: 5.6.2 + content-tag: 4.2.0 + debug: 4.4.3(supports-color@8.1.1) + fast-glob: 3.3.3 + fs-extra: 10.1.0 + jsdom: 25.0.1(supports-color@8.1.1) + send: 1.2.1(supports-color@8.1.1) + source-map-url: 0.4.1 + terser: 5.49.0 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@glint/template' + - bufferutil + - canvas + - supports-color + - utf-8-validate + '@embroider/vite@1.7.8(@embroider/core@4.6.3(@glint/template@1.8.0)(supports-color@8.1.1))(@glint/template@1.8.0)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -32108,8 +32597,69 @@ snapshots: transitivePeerDependencies: - supports-color + '@glint/ember-tsc@1.8.14(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': + dependencies: + '@glimmer/syntax': 0.95.0 + '@glint/template': 1.7.10 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28(typescript@5.9.3) + '@volar/language-service': 2.4.28(typescript@5.9.3) + '@volar/source-map': 2.4.28 + '@volar/test-utils': 2.4.28(typescript@5.9.3) + '@volar/typescript': 2.4.28(typescript@5.9.3) + content-tag: 4.2.0 + silent-error: 1.1.1(supports-color@8.1.1) + typescript: 5.9.3 + volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@5.9.3)) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@5.9.3))(typescript@5.9.3) + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + ember-source: 7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@glint/ember-tsc@1.8.14(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': + dependencies: + '@glimmer/syntax': 0.95.0 + '@glint/template': 1.7.10 + '@volar/kit': 2.4.28(typescript@6.0.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28(typescript@6.0.3) + '@volar/language-service': 2.4.28(typescript@6.0.3) + '@volar/source-map': 2.4.28 + '@volar/test-utils': 2.4.28(typescript@6.0.3) + '@volar/typescript': 2.4.28(typescript@6.0.3) + content-tag: 4.2.0 + silent-error: 1.1.1(supports-color@8.1.1) + typescript: 6.0.3 + volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + ember-source: 7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + '@glint/template@1.7.10': {} + '@glint/template@1.8.0': {} + '@glint/tsserver-plugin@2.5.20(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)': + dependencies: + '@glint/ember-tsc': 1.8.14(ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/typescript': 2.4.28(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + transitivePeerDependencies: + - ember-source + - supports-color + '@glint/tsserver-plugin@2.7.0(ember-source@7.2.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@glint/ember-tsc': 1.10.0(ember-source@7.2.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3) @@ -32187,15 +32737,6 @@ snapshots: '@inquirer/ansi@2.0.7': {} - '@inquirer/checkbox@5.2.1(@types/node@26.1.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/checkbox@5.2.1(@types/node@26.2.0)': dependencies: '@inquirer/ansi': 2.0.7 @@ -32205,13 +32746,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/confirm@6.1.1(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/confirm@6.1.1(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32219,18 +32753,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/core@11.2.1(@types/node@26.1.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.1.2) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/core@11.2.1(@types/node@26.2.0)': dependencies: '@inquirer/ansi': 2.0.7 @@ -32243,14 +32765,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/editor@5.2.2(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/external-editor': 3.0.3(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/editor@5.2.2(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32259,13 +32773,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/expand@5.1.1(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/expand@5.1.1(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32273,13 +32780,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/external-editor@3.0.3(@types/node@26.1.2)': - dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/external-editor@3.0.3(@types/node@26.2.0)': dependencies: chardet: 2.1.1 @@ -32289,13 +32789,6 @@ snapshots: '@inquirer/figures@2.0.7': {} - '@inquirer/input@5.1.2(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/input@5.1.2(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32303,13 +32796,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/number@4.1.1(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/number@4.1.1(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32317,14 +32803,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/password@5.1.1(@types/node@26.1.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/password@5.1.1(@types/node@26.2.0)': dependencies: '@inquirer/ansi': 2.0.7 @@ -32333,21 +32811,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/prompts@8.5.2(@types/node@26.1.2)': - dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@26.1.2) - '@inquirer/confirm': 6.1.1(@types/node@26.1.2) - '@inquirer/editor': 5.2.2(@types/node@26.1.2) - '@inquirer/expand': 5.1.1(@types/node@26.1.2) - '@inquirer/input': 5.1.2(@types/node@26.1.2) - '@inquirer/number': 4.1.1(@types/node@26.1.2) - '@inquirer/password': 5.1.1(@types/node@26.1.2) - '@inquirer/rawlist': 5.3.1(@types/node@26.1.2) - '@inquirer/search': 4.2.1(@types/node@26.1.2) - '@inquirer/select': 5.2.1(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/prompts@8.5.2(@types/node@26.2.0)': dependencies: '@inquirer/checkbox': 5.2.1(@types/node@26.2.0) @@ -32363,13 +32826,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/rawlist@5.3.1(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/rawlist@5.3.1(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32377,14 +32833,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/search@4.2.1(@types/node@26.1.2)': - dependencies: - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/search@4.2.1(@types/node@26.2.0)': dependencies: '@inquirer/core': 11.2.1(@types/node@26.2.0) @@ -32393,15 +32841,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/select@5.2.1(@types/node@26.1.2)': - dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@26.1.2) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.1.2) - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/select@5.2.1(@types/node@26.2.0)': dependencies: '@inquirer/ansi': 2.0.7 @@ -32411,10 +32850,6 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 - '@inquirer/type@4.0.7(@types/node@26.1.2)': - optionalDependencies: - '@types/node': 26.1.2 - '@inquirer/type@4.0.7(@types/node@26.2.0)': optionalDependencies: '@types/node': 26.2.0 @@ -32610,14 +33045,6 @@ snapshots: '@leichtgewicht/ip-codec@2.0.5': {} - '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.1.2))(@types/node@26.1.2)(listr2@10.2.2)': - dependencies: - '@inquirer/prompts': 8.5.2(@types/node@26.1.2) - '@inquirer/type': 4.0.7(@types/node@26.1.2) - listr2: 10.2.2 - transitivePeerDependencies: - - '@types/node' - '@listr2/prompt-adapter-inquirer@4.2.4(@inquirer/prompts@8.5.2(@types/node@26.2.0))(@types/node@26.2.0)(listr2@10.2.2)': dependencies: '@inquirer/prompts': 8.5.2(@types/node@26.2.0) @@ -33019,6 +33446,23 @@ snapshots: - rollup - supports-color + '@nullvoxpopuli/ember-vite@1.1.0(@glint/template@1.7.10)(@types/babel__core@7.20.5)(rollup@4.62.4)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@embroider/core': 4.6.3(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/macros': 1.20.6(@babel/core@7.29.7(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1) + '@embroider/vite': 1.7.8(@embroider/core@4.6.3(@glint/template@1.7.10)(supports-color@8.1.1))(@glint/template@1.7.10)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + '@nullvoxpopuli/ember-build-tooling-utils': 1.1.0(@types/babel__core@7.20.5)(rollup@4.62.4)(supports-color@8.1.1) + transitivePeerDependencies: + - '@glint/template' + - '@types/babel__core' + - bufferutil + - canvas + - rollup + - supports-color + - utf-8-validate + - vite + '@nullvoxpopuli/ember-vite@1.1.0(@glint/template@1.8.0)(@types/babel__core@7.20.5)(rollup@4.62.4)(supports-color@8.1.1)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -35410,7 +35854,7 @@ snapshots: dependencies: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/pacer-lite': 0.1.1 - '@tanstack/store': 0.11.0 + '@tanstack/store': 0.11.1 '@tanstack/form-devtools@0.2.34(@types/react@19.2.18)(csstype@3.2.3)(preact@10.29.8)(react@19.2.8)(solid-js@1.9.14)(vue@3.5.41(typescript@6.0.3))': dependencies: @@ -35502,7 +35946,7 @@ snapshots: '@tanstack/preact-form@1.30.5(preact@10.29.8)': dependencies: '@tanstack/form-core': 1.33.5 - '@tanstack/preact-store': 0.13.1(preact@10.29.8) + '@tanstack/preact-store': 0.13.2(preact@10.29.8) preact: 10.29.8(preact-render-to-string@6.7.0) '@tanstack/preact-hotkeys@0.10.0(preact@10.29.8)': @@ -35542,11 +35986,6 @@ snapshots: '@tanstack/query-core': 5.101.4 preact: 10.29.8(preact-render-to-string@6.7.0) - '@tanstack/preact-store@0.13.1(preact@10.29.8)': - dependencies: - '@tanstack/store': 0.11.0 - preact: 10.29.8(preact-render-to-string@6.7.0) - '@tanstack/preact-store@0.13.2(preact@10.29.8)': dependencies: '@tanstack/store': 0.11.1 @@ -35598,7 +36037,7 @@ snapshots: '@tanstack/react-form@1.33.5(@tanstack/react-start@1.168.44(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.14(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10))(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/form-core': 1.33.5 - '@tanstack/react-store': 0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@tanstack/react-store': 0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 optionalDependencies: '@tanstack/react-start': 1.168.44(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@7.2.0)(vite-plugin-solid@2.11.14(@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10))(solid-js@1.9.14)(supports-color@7.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0))(webpack@5.109.2(esbuild@0.28.1)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) @@ -35727,13 +36166,6 @@ snapshots: - vite-plugin-solid - webpack - '@tanstack/react-store@0.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@tanstack/store': 0.11.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) - '@tanstack/react-store@0.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/store': 0.11.1 @@ -36000,7 +36432,7 @@ snapshots: '@tanstack/svelte-form@1.33.5(svelte@5.56.8(@typescript-eslint/types@8.67.0))': dependencies: '@tanstack/form-core': 1.33.5 - '@tanstack/svelte-store': 0.12.0(svelte@5.56.8(@typescript-eslint/types@8.67.0)) + '@tanstack/svelte-store': 0.12.1(svelte@5.56.8(@typescript-eslint/types@8.67.0)) svelte: 5.56.8(@typescript-eslint/types@8.67.0) '@tanstack/svelte-hotkeys@0.10.0(svelte@5.56.8(@typescript-eslint/types@8.67.0))': @@ -36020,11 +36452,6 @@ snapshots: '@tanstack/query-core': 5.101.4 svelte: 5.56.8(@typescript-eslint/types@8.67.0) - '@tanstack/svelte-store@0.12.0(svelte@5.56.8(@typescript-eslint/types@8.67.0))': - dependencies: - '@tanstack/store': 0.11.0 - svelte: 5.56.8(@typescript-eslint/types@8.67.0) - '@tanstack/svelte-store@0.12.1(svelte@5.56.8(@typescript-eslint/types@8.67.0))': dependencies: '@tanstack/store': 0.11.1 @@ -36060,7 +36487,7 @@ snapshots: '@tanstack/vue-form@1.33.5(vue@3.5.41(typescript@6.0.3))': dependencies: '@tanstack/form-core': 1.33.5 - '@tanstack/vue-store': 0.11.0(vue@3.5.41(typescript@6.0.3)) + '@tanstack/vue-store': 0.11.1(vue@3.5.41(typescript@6.0.3)) vue: 3.5.41(typescript@6.0.3) transitivePeerDependencies: - '@vue/composition-api' @@ -36206,6 +36633,14 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' + '@tsrx/typescript-plugin@0.3.118(octane@0.1.21(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(typescript@6.0.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/typescript': 2.4.28(typescript@6.0.3) + typescript: 6.0.3 + optionalDependencies: + octane: 0.1.21(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)) + '@tsrx/typescript-plugin@0.3.118(octane@0.1.36(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: '@volar/language-core': 2.4.28 @@ -36273,7 +36708,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@types/deep-eql@4.0.2': {} @@ -36334,10 +36769,6 @@ snapshots: '@types/minimatch@3.0.5': {} - '@types/node@26.1.2': - dependencies: - undici-types: 8.3.0 - '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -39241,6 +39672,14 @@ snapshots: content-tag: 4.2.0 oxc-parser: 0.130.0 + ember-modifier@4.3.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): + dependencies: + '@embroider/addon-shim': 1.10.3(supports-color@8.1.1) + decorator-transforms: 2.4.0(@babel/core@7.29.7(supports-color@8.1.1)) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-qunit@9.1.0(@ember/test-helpers@5.4.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(qunit@2.26.0)(supports-color@8.1.1): dependencies: '@ember/test-helpers': 5.4.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) @@ -39260,6 +39699,30 @@ snapshots: transitivePeerDependencies: - supports-color + ember-source@7.1.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1): + dependencies: + '@babel/core': 7.29.7(supports-color@8.1.1) + '@embroider/addon-shim': 1.10.3(supports-color@8.1.1) + '@glimmer/component': 2.1.1(supports-color@8.1.1) + '@simple-dom/interface': 1.4.0 + backburner.js: 2.8.0 + broccoli-file-creator: 2.1.1 + chalk: 4.1.2 + ember-cli-babel: 8.3.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + ember-cli-get-component-path-option: 1.0.0 + ember-cli-normalize-entity-name: 1.0.0(supports-color@8.1.1) + ember-cli-path-utils: 1.0.0 + ember-cli-string-utils: 1.1.0 + ember-cli-typescript-blueprint-polyfill: 0.1.0(supports-color@8.1.1) + ember-router-generator: 2.0.0(supports-color@8.1.1) + inflection: 2.0.1 + route-recognizer: 0.3.4 + semver: 7.8.5 + silent-error: 1.1.1(supports-color@8.1.1) + simple-html-tokenizer: 0.5.11 + transitivePeerDependencies: + - supports-color + ember-source@7.2.0(@glimmer/component@2.1.1(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) @@ -39318,7 +39781,7 @@ snapshots: engine.io@6.6.9(supports-color@8.1.1): dependencies: '@types/cors': 2.8.19 - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@types/ws': 8.18.1 accepts: 1.3.8 base64id: 2.0.0 @@ -41015,6 +41478,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.6.1: {} + jiti@2.7.0: {} jju@1.4.0: {} @@ -41442,7 +41907,7 @@ snapshots: magicast@0.5.3: dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/types': 7.29.7 source-map-js: 1.2.1 @@ -41959,6 +42424,19 @@ snapshots: obug@2.1.4: {} + octane@0.1.21(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)): + dependencies: + '@tsrx/core': 0.1.56(@typescript-eslint/types@8.67.0) + '@types/react': 19.2.18 + devalue: 5.8.2 + esrap: 2.3.0(@typescript-eslint/types@8.67.0) + optionalDependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@typescript-eslint/types' + octane@0.1.36(@typescript-eslint/types@8.67.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.7)(sass@1.101.0)(sugarss@5.0.1(postcss@8.5.26))(terser@5.49.0)(yaml@2.9.0)): dependencies: '@tsrx/core': 0.1.56(@typescript-eslint/types@8.67.0)