tutorial
Build a real-time trading terminal: streaming prices, live P&L
Last updated 5 September 2026
A trading desk is several views of one market at once: the positions you hold, the order book behind a symbol, and a running log of your fills. They all move together, many times a second, and they all come from the same feed. This tutorial builds that desk from one live stream: three grids, a chart and keyboard trading, with live mark-to-market P&L and no backend to run.
You will build it with a stand-in socket so it runs anywhere, then change a single line to point it at a real feed. The finished terminal is one click away if you want to see the destination first.
Open the finished terminal in the sandbox
The problem: one feed, a whole desk
Prices arrive in a torrent, and every one of them touches more than one view: a tick reprices a position, moves the P&L and shifts the top of the book. Wire each panel to its own connection and you are reconciling several streams that carry the same data. Repaint the whole screen on every message and the desk stutters. The shape that holds up is a router in front and plain grids behind: one connection arrives, a router splits each record to the panel it belongs in, and each grid updates only the rows that changed, in place, keeping scroll and selection.
Set up the page
Load three things from the CDN with ordinary script tags: the grid, the
charts module and the data-router module. No build step and no import: the
grid is on the global LatticeGrid, the charts module adds
createChart to it, and the data-router module is on
LatticeGridDataRouter. On localhost the grid is free to use; a
deployed site is licensed per domain, which the sandbox already carries for
you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/lattice-grid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/modules/charts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.32.0/modules/data-router.min.js"></script>
Lay out three panels and a strip for the chart. The grids mount by id.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #0e1420; color: #d8dee9; }
.hint { font-size: 12px; color: #8c97a6; margin: 0 0 12px; }
.hint b { color: #cfd6e4; }
.desk { display: grid; grid-template-columns: 1.2fr 1fr; grid-template-rows: auto auto; gap: 12px; }
.panel { background: #131b2a; border: 1px solid #232f45; border-radius: 10px; overflow: hidden; min-width: 0; }
.panel h2 { font-size: 12px; margin: 0; padding: 8px 12px; border-bottom: 1px solid #1d2740; color: #9fb0cc; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.grid { height: 300px; }
#chart { height: 220px; }
@media (max-width: 900px) { .desk { grid-template-columns: 1fr; } }
</style>
<p class="hint">Click a symbol in <b>Positions</b> to load its book. With a symbol selected, press <b>B</b> to buy a lot and <b>S</b> to sell.</p>
<div class="desk">
<div class="panel"><h2>Positions</h2><div id="positions" class="grid"></div></div>
<div class="panel"><h2>Depth</h2><div id="depth" class="grid"></div></div>
<div class="panel"><h2>Blotter</h2><div id="blotter" class="grid"></div></div>
<div class="panel"><h2>P&L by symbol</h2><div id="chart"></div></div>
</div>
Build the feed
A market feed sends an opening snapshot, then a stream of price changes. The helper below does exactly that from a generator, on a timer, and presents the same surface as the browser's WebSocket. Because it matches the real thing, the code you write against it is the code you ship.
// A serverless stand-in for a market-data socket. It opens, sends a snapshot,
// then streams deltas on a timer from a generator you hand it, and presents the
// same surface as the browser's WebSocket. To go live, swap the one line that
// constructs it for: new WebSocket(url)
function rng(seed) {
var a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class MockWebSocket {
constructor({ feed, rate = 250, seed = 1, snapshotDelay = 40 }) {
this.readyState = 0;
this.onopen = this.onmessage = this.onclose = this.onerror = null;
this._feed = feed; this._rate = rate; this._timer = null;
setTimeout(() => this._open(), snapshotDelay);
}
send() {}
close() { this.readyState = 3; if (this._timer) clearTimeout(this._timer); this._timer = null; this.onclose && this.onclose({ type: 'close' }); }
_open() { this.readyState = 1; this.onopen && this.onopen({ type: 'open' }); this._pump(); this._schedule(); }
_schedule() { if (this._timer || this.readyState !== 1) return; this._timer = setTimeout(() => { this._timer = null; this._pump(); this._schedule(); }, this._rate); }
_pump() { if (this.readyState !== 1) return; var next = this._feed.next(); if (next.done) return this.close(); this.onmessage && this.onmessage({ type: 'message', data: JSON.stringify(next.value) }); }
}
Model the book
One book holds each symbol's position and last price, and both the feed and your own orders read and write it, so the P&L on screen is always priced off the same numbers. A short position, a negative quantity, makes money when the price falls, and the sign in the P&L handles that with no special case.
// The book is the shared truth for both the feed and your own orders. Each
// symbol starts with a position, so the terminal shows live P&L on arrival; a
// keyboard trade changes the quantity; every price tick reprices the P&L.
var SYMBOLS = [
{ symbol: 'AAPL', last: 224.10, qty: 300, avg: 210.4 },
{ symbol: 'MSFT', last: 419.80, qty: 150, avg: 405.1 },
{ symbol: 'NVDA', last: 121.55, qty: 800, avg: 118.2 },
{ symbol: 'AMZN', last: 186.30, qty: -200, avg: 190.7 },
{ symbol: 'META', last: 512.40, qty: 120, avg: 498.0 },
{ symbol: 'TSLA', last: 244.90, qty: -100, avg: 250.2 },
];
var book = {};
SYMBOLS.forEach(function (s) { book[s.symbol] = { last: s.last, qty: s.qty, avg: s.avg, chg: 0 }; });
// One position record, with the P&L priced off the book. A short position (a
// negative quantity) makes money when the price falls, which the sign handles.
function positionRow(symbol) {
var b = book[symbol];
return { type: 'position', symbol: symbol, qty: b.qty, avgPx: b.avg, lastPx: b.last, chg: b.chg, pnl: Math.round((b.last - b.avg) * b.qty * 100) / 100 };
}
// One order-book level. Five bids and five asks straddle the last price.
function depthRows(symbol) {
var b = book[symbol]; var rows = [];
for (var i = 1; i <= 5; i++) {
rows.push({ type: 'depth', id: symbol + '-B' + i, symbol: symbol, side: 'bid', level: i, px: Math.round((b.last - i * 0.05) * 100) / 100, size: 100 * i });
rows.push({ type: 'depth', id: symbol + '-A' + i, symbol: symbol, side: 'ask', level: i, px: Math.round((b.last + i * 0.05) * 100) / 100, size: 100 * i });
}
return rows;
}
The generator is where the market lives. It emits a snapshot of every position and its book, then nudges a few prices on each tick and refreshes the levels behind them.
// One mixed stream: position reprices and depth changes. Every record carries a
// 'type', the field the router partitions on. The snapshot fills all three
// panels; each delta nudges a few prices and refreshes the book behind them.
function* marketFeed(seed) {
var rand = rng(seed || 7);
var names = SYMBOLS.map(function (s) { return s.symbol; });
var snap = [];
names.forEach(function (n) { snap.push(positionRow(n)); depthRows(n).forEach(function (r) { snap.push(r); }); });
yield { kind: 'snapshot', rows: snap };
for (;;) {
var changes = [];
var moved = {};
var hits = 1 + Math.floor(rand() * 3);
for (var i = 0; i < hits; i++) {
var sym = names[Math.floor(rand() * names.length)];
var b = book[sym];
var next = Math.round(b.last * (1 + (rand() - 0.5) * 0.006) * 100) / 100;
b.chg = Math.round((next - b.last) * 100) / 100;
b.last = next;
moved[sym] = true;
}
Object.keys(moved).forEach(function (sym) {
changes.push({ op: 'upsert', row: positionRow(sym) });
depthRows(sym).forEach(function (r) { changes.push({ op: 'upsert', row: r }); });
});
yield { kind: 'delta', changes: changes };
}
}
Build the panels
Three grids: positions, the order-book depth, and the blotter. Two touches make them feel live. A flash on each changed cell shows a reprice as it lands, and conditional formatting colours the tick direction and the P&L, so gains read green and losses red without a line of drawing code. The P&L column carries a running total, so the footer is the book's live mark-to-market and it recomputes on every tick.
var money = { style: 'currency', currency: 'USD', decimals: 2 };
var upDown = [
{ id: 'up', when: { op: 'gt', value: 0 }, style: { color: '#3ddc97' } },
{ id: 'down', when: { op: 'lt', value: 0 }, style: { color: '#ff6b6b' } },
];
var positions = LatticeGrid.createGrid(document.getElementById('positions'), {
rowKey: 'symbol', theme: 'dark', selection: 'single', statusBar: true,
// The flash a changed cell shows, so a reprice is visible as it lands.
highlightOnChange: { duration: 350 },
// Conditional formatting on the tick direction and on P&L: gains green,
// losses red, priced live off the same feed.
formatting: { chg: upDown, pnl: upDown },
columns: [
{ field: 'symbol', title: 'Symbol', layout: { width: 90, pin: 'start' } },
{ field: 'qty', title: 'Qty', type: 'number', total: 'sum' },
{ field: 'avgPx', title: 'Avg', type: 'number', format: money },
{ field: 'lastPx', title: 'Last', type: 'number', format: money },
{ field: 'chg', title: 'Chg', type: 'number', format: { decimals: 2 } },
// A running total: the P&L column sums across the book and recomputes on
// every tick, so the footer is the book's live mark-to-market.
{ field: 'pnl', title: 'P&L', type: 'number', format: money, total: 'sum' },
],
});
var depth = LatticeGrid.createGrid(document.getElementById('depth'), {
rowKey: 'id', theme: 'dark',
formatting: { side: [{ id: 'bid', when: { op: 'eq', value: 'bid' }, style: { color: '#3ddc97' } }, { id: 'ask', when: { op: 'eq', value: 'ask' }, style: { color: '#ff6b6b' } }] },
columns: [
{ field: 'side', title: 'Side', layout: { width: 80 } },
{ field: 'level', title: 'Lvl', type: 'number', layout: { width: 70 } },
{ field: 'px', title: 'Price', type: 'number', format: money },
{ field: 'size', title: 'Size', type: 'number', total: 'sum' },
],
});
var blotter = LatticeGrid.createGrid(document.getElementById('blotter'), {
rowKey: 'id', theme: 'dark',
formatting: { side: [{ id: 'buy', when: { op: 'eq', value: 'BUY' }, style: { color: '#3ddc97' } }, { id: 'sell', when: { op: 'eq', value: 'SELL' }, style: { color: '#ff6b6b' } }] },
columns: [
{ field: 'time', title: 'Time', layout: { width: 100 } },
{ field: 'symbol', title: 'Symbol', layout: { width: 90 } },
{ field: 'side', title: 'Side', layout: { width: 80 } },
{ field: 'qty', title: 'Qty', type: 'number' },
{ field: 'px', title: 'Price', type: 'number', format: money },
],
});
Route the feed
One router, keyed on the record type. Attach each grid to the value it wants and it only ever sees its own slice: positions receives reprices, depth receives book levels, and the blotter receives fills. The one feed fans out to all three.
// One router, keyed on 'type'. Each grid is attached to the value it wants and
// only ever sees its own slice: positions, depth levels and trades from one feed.
var router = LatticeGridDataRouter.createDataRouter({ key: 'type', rowKey: 'symbol' });
router.attach(positions, 'position', { rowKey: 'symbol' });
router.attach(depth, 'depth', { rowKey: 'id' });
router.attach(blotter, 'trade', { rowKey: 'id' });
Link positions to depth
A desk is more useful when the views relate. Cross-grid selection ties the positions grid to the depth grid on the symbol: click a position and the depth grid narrows to that symbol's book on its own. Neither grid holds a reference to the other; the router recomputes what depth shows and pushes it through the same keyed path.
// Cross-grid selection: choose a symbol in Positions and the Depth grid narrows
// to that symbol's book. Neither grid holds a reference to the other; the router
// recomputes what Depth shows and pushes it through the same keyed path.
router.link(positions, depth, { from: 'symbol', to: 'symbol' });
A chart that follows the book
The chart reads the positions grid, so it is driven by the same feed and the same filters with no extra wiring. As the book reprices, the bars move.
// The chart reads the Positions grid, so it follows the same feed and the same
// filters. As the book reprices, the bars move.
var chart = LatticeGrid.createChart({ grid: positions, container: '#chart', type: 'bar', x: 'symbol', y: 'pnl', title: 'P&L by symbol' });
Now connect the socket. One handler drives everything: the snapshot hydrates all three panels at once, and each delta lands in place through the keyed path.
// One socket, one handler. The snapshot hydrates all three panels; each delta
// is applied in place through the keyed path, so scroll and selection hold.
var socket = new MockWebSocket({ feed: marketFeed(7), rate: 220 });
socket.onmessage = function (event) {
var message = JSON.parse(event.data);
if (message.kind === 'snapshot') router.load(message.rows);
else router.apply(message.changes);
};
Keyboard trading
A trader's hands stay on the keyboard. With a symbol selected in positions, B buys a lot and S sells one: the order updates the book, reprices the position and appends the fill to the blotter, all through the same router the feed uses. The weighted-average cost only moves when you add to a position in its own direction, which the check below handles.
// Keyboard trading. With a symbol selected in Positions, B buys a lot and S
// sells one: the order updates the book, reprices the position and appends the
// fill to the blotter, all through the same router the feed uses.
var tradeSeq = 0;
function trade(symbol, side) {
var b = book[symbol];
var lot = side === 'BUY' ? 100 : -100;
// Weighted-average cost only moves when you add to a position in its own
// direction; reducing or crossing leaves the average where it was.
if ((b.qty >= 0) === (lot >= 0) && b.qty + lot !== 0) {
b.avg = Math.round((b.avg * Math.abs(b.qty) + b.last * Math.abs(lot)) / Math.abs(b.qty + lot) * 100) / 100;
}
b.qty += lot;
var now = new Date().toTimeString().slice(0, 8);
router.apply([
{ op: 'upsert', row: { type: 'trade', id: 'T' + (++tradeSeq), time: now, symbol: symbol, side: side, qty: Math.abs(lot), px: b.last } },
{ op: 'upsert', row: positionRow(symbol) },
]);
}
document.getElementById('positions').addEventListener('keydown', function (e) {
var key = e.key.toLowerCase();
if (key !== 'b' && key !== 's') return;
var selected = positions.selection.keys();
if (!selected.length) return;
trade(selected[0], key === 'b' ? 'BUY' : 'SELL');
});
That is the whole terminal: one feed, three panels, a following chart, a link between two grids and orders from the keyboard. Run it in the sandbox and trade against the live book.
Go to production
When there is a real feed to talk to, the change is one line. Everything that parses messages and routes them stays exactly as written, because the stand-in was built to the real socket's surface from the start.
// The whole change from mock to live is this one line:
var socket = new WebSocket('wss://feed.example.com/marketdata');
// everything below it stays exactly as written.
What you built
One stream drives a whole desk: positions, depth and a blotter that reprice in place, a P&L total that stays live, a chart that follows the book, and orders from the keyboard, on a single connection. Swapping the mock for a real socket is the only change between this page and production.
Next, see the Data Router demo for the partition idea on its own, or the demo catalogue for the streaming, formatting and chart features each panel uses.