Skip to content

Commit c6cef58

Browse files
rescale synthetic prices to cents and throttle editorial cells to 4 Hz (#26)
Two related polish changes from observing the live demo at https://meridian-orderbook.pages.dev. First, the synthetic generator was emitting prices as integer dollars in a narrow [95, 105] band, so the minimum spread was always $1 and the "Spread 2.00" cell read as "200.0 bp wide" on the dashboard. That is technically a $2 spread on a $100 stock, which is enormous for a liquid name and looked broken. Rescale the price model: anchor at 10000 cents ($100.00), reference walks +/-2 cents per event with bounded mean reversion to anchor, limit orders cluster within 5 cents of the reference, per-order quantity range [5, 40]. Spreads now land at 1 to 5 bp ("tight"), the dashboard reads like a real instrument. Local verification at seed 42 over 20s steady state: top size median 761 in [451, 1700], spread median 2 cents in [1, 5]. formatPrice and formatSpread in the frontend divide by 100 before rendering so the cells display "$100.01" instead of "10001". Second, the editorial cells (Hero Last, Spread, Top size; Ladder L1 row; DepthChart geometry; PerfPanel book-state cells) were updating on every WebSocket delta at 30 Hz. Real-time financial dashboards (Bloomberg, Refinitiv, Eikon) refresh headline numerics at 2 to 5 Hz because faster reads as flicker rather than as throughput. Add a displayedTop slice to the Zustand store that mirrors top at a throttled cadence; a new useDisplayThrottle hook runs the mirror at 4 Hz (250 ms). Selectors reading displayedTop only re-render when the reference changes, so the components that need live data (the PerfPanel wire counters, the perf histogram) continue to read top directly. A fresh snapshot eagerly populates displayedTop so the dashboard leaves the connecting skeleton immediately. Tests: * `tests/unit/test_ws_origin.cpp` and the rest of the C++ suite stay at 182/182. * Three new Vitest cases cover the throttle: snapshot eagerly populates displayedTop, deltas update top but leave displayedTop alone until flushDisplayed is called, flushDisplayed is a no-op when top has not advanced. Total Vitest count: 18/18.
1 parent c1f7f4e commit c6cef58

10 files changed

Lines changed: 123 additions & 45 deletions

File tree

apps/server/main.cpp

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -204,29 +204,31 @@ int main(int argc, char** argv) {
204204

205205
// Engine thread: synthetic limit / market / cancel mix.
206206
//
207-
// The price model tracks a slowly drifting reference mid: every event
208-
// nudges it by at most one tick with a strong pull toward the anchor
209-
// (kAnchor = 100), so the displayed top of book moves like a real
210-
// instrument rather than skipping uniformly across a wide range. New
211-
// limit orders cluster near the current reference (one to four ticks
212-
// off depending on side), so a tight visible spread builds and
213-
// moves a tick at a time. The event mix favors limit orders so the
214-
// book accumulates depth between cancels and market sweeps.
207+
// The price model tracks a slowly drifting reference mid in cents
208+
// (kAnchor = 10000 = $100.00), so the displayed top of book moves
209+
// like a real instrument with realistic 1-to-3-cent spreads. New
210+
// limit orders cluster near the current reference within 30 cents
211+
// on either side, so a tight visible spread builds and moves at
212+
// cent granularity. The event mix favors limit and cancel orders
213+
// so the book reaches a steady state in the low hundreds of
214+
// resting quantity rather than accumulating indefinitely.
215215
std::thread engine_thr([&]() {
216216
std::mt19937_64 rng(cfg.seed);
217-
constexpr int kAnchor = 100;
218-
constexpr int kPriceFloor = 92;
219-
constexpr int kPriceCeiling = 108;
217+
constexpr int kAnchor = 10000; // $100.00 expressed in cents
218+
constexpr int kPriceFloor = 9000; // $90.00
219+
constexpr int kPriceCeiling = 11000; // $110.00
220220
int reference = kAnchor;
221221

222-
std::uniform_int_distribution<int> offset_dist(0, 3); // ticks off the mid
223-
std::uniform_int_distribution<int> drift_dist(-1, 1); // small random walk
224-
// Small order sizes keep the per-level resting depth in a
225-
// visually legible range (top size lands around 100 to 300
226-
// qty in steady state, not 1000+). The bench binary still
227-
// exercises the engine at much higher per-event quantities;
228-
// the live demo runs in a range a viewer can read.
229-
std::uniform_int_distribution<int> qty_dist(1, 6);
222+
// Orders cluster within a 5-cent band of the reference so the
223+
// visible top of book carries real depth rather than scattering
224+
// thin across many levels.
225+
std::uniform_int_distribution<int> offset_dist(0, 5); // cents off the mid
226+
std::uniform_int_distribution<int> drift_dist(-2, 2); // cents per event
227+
// Per-order quantities are in the 5 to 40 range so each event
228+
// measurably shifts the visible numerals but the per-level
229+
// size stays in the readable hundreds-to-low-thousands range
230+
// at steady state.
231+
std::uniform_int_distribution<int> qty_dist(5, 40);
230232
std::uniform_int_distribution<int> bucket(0, 99);
231233
std::bernoulli_distribution side_dist(0.5);
232234

@@ -246,21 +248,21 @@ int main(int argc, char** argv) {
246248
ev.ts = ++ts;
247249
ev.side = side_dist(rng) ? meridian::Side::Buy : meridian::Side::Sell;
248250

249-
// Mean-reverting random walk: each event nudges the
250-
// reference at most one tick. At the anchor the step is a
251-
// free uniform draw over {-1, 0, +1}; away from the anchor
252-
// the away-direction step is suppressed so the reference
253-
// drifts back toward the anchor over time. This keeps the
254-
// displayed top of book within a few ticks of kAnchor.
251+
// Mean-reverting random walk in cents. At the anchor the
252+
// step is a free uniform draw over the drift_dist range;
253+
// away from the anchor the away-from-anchor step is
254+
// suppressed so the reference drifts back over time.
255+
// Toward-anchor steps of any magnitude are allowed (so the
256+
// walk can correct quickly if it has drifted multiple
257+
// cents off the anchor).
255258
const int raw_step = drift_dist(rng);
256259
const int pull = reference > kAnchor ? -1 : (reference < kAnchor ? 1 : 0);
257260
int step;
258-
if (pull == 0) {
259-
step = raw_step;
260-
} else if (raw_step == 0 || raw_step == pull) {
261+
if (pull == 0 || raw_step == 0) {
261262
step = raw_step;
262263
} else {
263-
step = 0;
264+
const bool toward_anchor = (raw_step * pull) > 0;
265+
step = toward_anchor ? raw_step : 0;
264266
}
265267
reference += step;
266268
if (reference < kPriceFloor) reference = kPriceFloor;

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { Hero } from './components/Hero';
66
import { Ladder } from './components/Ladder';
77
import { PerfPanel } from './components/PerfPanel';
88
import { Tape } from './components/Tape';
9+
import { useDisplayThrottle } from './hooks/useDisplayThrottle';
910
import { useMeridianStream } from './hooks/useMeridianStream';
1011
import { useDashboard } from './store/dashboard';
1112

1213
function App() {
1314
useMeridianStream();
15+
useDisplayThrottle();
1416
const state = useDashboard((s) => s.connectionState);
1517
const fadedNumerics = state === 'stalled' || state === 'disconnected';
1618
return (

frontend/src/components/DepthChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { SectionHead } from './SectionHead';
77
// section aux makes it clear this is the v1 view until the extended
88
// snapshot payload lands.
99
export function DepthChart() {
10-
const top = useDashboard((s) => s.top);
10+
const top = useDashboard((s) => s.displayedTop);
1111
const state = useDashboard((s) => s.connectionState);
1212
const ready = state !== 'connecting' && top !== null;
1313

frontend/src/components/Hero.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function Skeleton({ w = 80, h = 32 }: { w?: number; h?: number }) {
1313
}
1414

1515
function StatLast() {
16-
const top = useDashboard((s) => s.top);
16+
const top = useDashboard((s) => s.displayedTop);
1717
const color = useDashboard((s) => s.lastTickColor);
1818
const ready = useDashboard((s) => s.connectionState !== 'connecting');
1919
const bidPx = top?.bidPx ?? null;
@@ -55,7 +55,7 @@ function StatLast() {
5555
}
5656

5757
function StatSpread() {
58-
const top = useDashboard((s) => s.top);
58+
const top = useDashboard((s) => s.displayedTop);
5959
const ready = useDashboard((s) => s.connectionState !== 'connecting');
6060
const s = formatSpread(top?.bidPx ?? null, top?.askPx ?? null);
6161
return (
@@ -85,7 +85,7 @@ function StatSpread() {
8585
}
8686

8787
function StatBookSize() {
88-
const top = useDashboard((s) => s.top);
88+
const top = useDashboard((s) => s.displayedTop);
8989
const ticks = useDashboard((s) => s.ticksSinceConnect);
9090
const ready = useDashboard((s) => s.connectionState !== 'connecting');
9191
const totalQty = (top?.bidQty ?? 0) + (top?.askQty ?? 0);
@@ -123,7 +123,7 @@ function HeroIdentity() {
123123
const state = useDashboard((s) => s.connectionState);
124124
const symbol = useDashboard((s) => s.selectedSymbol);
125125
const ticks = useDashboard((s) => s.ticksSinceConnect);
126-
const top = useDashboard((s) => s.top);
126+
const top = useDashboard((s) => s.displayedTop);
127127
const meta = symbolMeta(symbol);
128128

129129
if (state === 'connecting') {

frontend/src/components/Ladder.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function SpreadRow({ bidPx, askPx }: { bidPx: number | null; askPx: number | nul
8787
}
8888

8989
export function Ladder() {
90-
const top = useDashboard((s) => s.top);
90+
const top = useDashboard((s) => s.displayedTop);
9191
const state = useDashboard((s) => s.connectionState);
9292
const ready = state !== 'connecting';
9393

frontend/src/components/PerfPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function PerfLatency() {
8787
}
8888

8989
function PerfBookState() {
90-
const top = useDashboard((s) => s.top);
90+
const top = useDashboard((s) => s.displayedTop);
9191
const ticks = useDashboard((s) => s.ticksSinceConnect);
9292
return (
9393
<div data-element="PerfBookState" className="mb-8">
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useEffect } from 'react';
2+
import { useDashboard } from '../store/dashboard';
3+
4+
// Default throttle interval. 250 ms (4 Hz) is roughly the slowest the
5+
// eye can absorb numeric updates without feeling broken; faster reads
6+
// as flicker, slower reads as stale. The Bloomberg / Refinitiv
7+
// terminal cadence is in the 2 to 5 Hz range and this lands in the
8+
// middle of that band.
9+
const DEFAULT_INTERVAL_MS = 250;
10+
11+
// Throttle the rendered editorial cells (Hero stats, Ladder, Tape) to
12+
// a human-legible cadence while the underlying WebSocket stream keeps
13+
// running at 30 Hz for the perf panel and depth chart inputs. The
14+
// throttle works by mirroring `top` into `displayedTop` on a fixed
15+
// interval; selectors reading `displayedTop` only re-render when the
16+
// store entry changes reference.
17+
export function useDisplayThrottle(intervalMs: number = DEFAULT_INTERVAL_MS): void {
18+
const flush = useDashboard((s) => s.flushDisplayed);
19+
useEffect(() => {
20+
const id = window.setInterval(flush, intervalMs);
21+
return () => window.clearInterval(id);
22+
}, [flush, intervalMs]);
23+
}

frontend/src/lib/format.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
// Display formatting helpers. Numbers from the wire arrive as integer
2-
// engine ticks; the synthetic event generator in apps/server emits
3-
// prices in the small 95..105 range, so the dashboard renders raw
4-
// ticks as-is rather than pretending they are dollars. When the
5-
// extended snapshot payload (real ITCH prices, currency-scaled) lands,
6-
// this module is the single place to widen the conversion.
2+
// engine ticks where one tick equals one cent (so $100.00 corresponds
3+
// to 10000 ticks). The helpers below divide by 100 to display as
4+
// currency with two decimal places.
5+
6+
const CENTS_PER_DOLLAR = 100;
77

88
export function formatPrice(px: number | null): string {
99
if (px === null) return '—';
10-
return px.toFixed(2);
10+
return (px / CENTS_PER_DOLLAR).toFixed(2);
1111
}
1212

1313
export function formatQty(qty: number): string {
@@ -22,12 +22,12 @@ export function formatSpread(bidPx: number | null, askPx: number | null): {
2222
if (bidPx === null || askPx === null) {
2323
return { abs: '—', bps: '—', band: 'normal' };
2424
}
25-
const spread = askPx - bidPx;
25+
const spreadCents = askPx - bidPx;
2626
const mid = (askPx + bidPx) / 2;
27-
const bps = mid > 0 ? (spread / mid) * 10_000 : 0;
27+
const bps = mid > 0 ? (spreadCents / mid) * 10_000 : 0;
2828
const band = bps < 4 ? 'tight' : bps > 12 ? 'wide' : 'normal';
2929
return {
30-
abs: spread.toFixed(2),
30+
abs: (spreadCents / CENTS_PER_DOLLAR).toFixed(2),
3131
bps: bps.toFixed(1),
3232
band,
3333
};

frontend/src/store/dashboard.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,34 @@ describe('useDashboard', () => {
8787
expect(s.reconnectAttempt).toBe(3);
8888
expect(s.reconnectInMs).toBe(2_000);
8989
});
90+
91+
it('a snapshot eagerly populates displayedTop so the dashboard leaves the connecting skeleton immediately', () => {
92+
useDashboard.getState().applySnapshot(tob(10000, 10001, 1), 0);
93+
const s = useDashboard.getState();
94+
expect(s.displayedTop).not.toBeNull();
95+
expect(s.displayedTop?.bidPx).toBe(10000);
96+
expect(s.displayedTop?.askPx).toBe(10001);
97+
});
98+
99+
it('deltas update top but leave displayedTop alone until flushDisplayed is called', () => {
100+
useDashboard.getState().applySnapshot(tob(10000, 10001, 1), 0);
101+
const initial = useDashboard.getState().displayedTop;
102+
useDashboard.getState().applyDelta(tob(10001, 10002, 2), 0);
103+
useDashboard.getState().applyDelta(tob(10002, 10003, 3), 0);
104+
const afterDeltas = useDashboard.getState();
105+
expect(afterDeltas.top?.ts).toBe(3);
106+
// displayedTop still references the snapshot, not the latest delta:
107+
expect(afterDeltas.displayedTop).toBe(initial);
108+
109+
useDashboard.getState().flushDisplayed();
110+
const afterFlush = useDashboard.getState();
111+
expect(afterFlush.displayedTop?.ts).toBe(3);
112+
});
113+
114+
it('flushDisplayed is a no-op when top has not changed since the last flush', () => {
115+
useDashboard.getState().applySnapshot(tob(10000, 10001, 1), 0);
116+
const ref = useDashboard.getState().displayedTop;
117+
useDashboard.getState().flushDisplayed();
118+
expect(useDashboard.getState().displayedTop).toBe(ref);
119+
});
90120
});

frontend/src/store/dashboard.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ export type DashboardState = {
3030
connectionState: ConnectionState;
3131
selectedSymbol: Symbol;
3232
top: TopOfBook | null;
33+
// `displayedTop` mirrors `top` at a throttled cadence (see
34+
// hooks/useDisplayThrottle). The editorial cells (Hero stats, Ladder,
35+
// Tape) read this so their numerals refresh at a human-legible rate;
36+
// the wire counters and depth-chart inputs continue to read `top` so
37+
// the perf panel and chart stay accurate.
38+
displayedTop: TopOfBook | null;
3339
prevLast: LastTick | null;
3440
lastTickColor: 'up' | 'down' | 'flat';
3541
ticksSinceConnect: number;
@@ -45,6 +51,7 @@ export type DashboardState = {
4551
applyDelta: (t: TopOfBook, bytes: number) => void;
4652
notePerfTick: (perSec: { deltas: number; bytes: number }) => void;
4753
setReconnect: (attempt: number, inMs: number) => void;
54+
flushDisplayed: () => void;
4855
reset: () => void;
4956
};
5057

@@ -61,6 +68,7 @@ export const useDashboard = create<DashboardState>((set) => ({
6168
connectionState: 'connecting',
6269
selectedSymbol: 'AAPL',
6370
top: null,
71+
displayedTop: null,
6472
prevLast: null,
6573
lastTickColor: 'flat',
6674
ticksSinceConnect: 0,
@@ -75,6 +83,7 @@ export const useDashboard = create<DashboardState>((set) => ({
7583
set({
7684
selectedSymbol: s,
7785
top: null,
86+
displayedTop: null,
7887
prevLast: null,
7988
lastTickColor: 'flat',
8089
ticksSinceConnect: 0,
@@ -88,6 +97,10 @@ export const useDashboard = create<DashboardState>((set) => ({
8897
const history = lastMid !== null ? [{ px: lastMid, ts: t.ts }] : [];
8998
return {
9099
top: t,
100+
// A snapshot is the start of a stream: mirror immediately to
101+
// displayedTop so the editorial cells leave the connecting
102+
// skeleton without waiting for the next throttle tick.
103+
displayedTop: t,
91104
// A snapshot is the start of a stream; reset prior-tick state.
92105
prevLast: null,
93106
lastTickColor: 'flat',
@@ -155,10 +168,18 @@ export const useDashboard = create<DashboardState>((set) => ({
155168
connectionState: 'disconnected',
156169
}),
157170

171+
// Mirror the latest `top` into `displayedTop`. Called from a 4 Hz
172+
// interval in App.tsx (via useDisplayThrottle), this throttles
173+
// rendering of the editorial cells without throttling the underlying
174+
// wire stream.
175+
flushDisplayed: () =>
176+
set((prev) => (prev.top === prev.displayedTop ? {} : { displayedTop: prev.top })),
177+
158178
reset: () =>
159179
set({
160180
connectionState: 'connecting',
161181
top: null,
182+
displayedTop: null,
162183
prevLast: null,
163184
lastTickColor: 'flat',
164185
ticksSinceConnect: 0,

0 commit comments

Comments
 (0)