@stocksharp/trading-controls 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +92 -6
  2. package/dist/esm/active-orders-widget.js +9 -0
  3. package/dist/esm/active-orders-widget.js.map +1 -1
  4. package/dist/esm/control-types.js +3 -0
  5. package/dist/esm/control-types.js.map +1 -1
  6. package/dist/esm/grid-menu.js +54 -0
  7. package/dist/esm/grid-menu.js.map +1 -0
  8. package/dist/esm/index.js +8 -0
  9. package/dist/esm/index.js.map +1 -1
  10. package/dist/esm/order-entry-widget.js +654 -0
  11. package/dist/esm/order-entry-widget.js.map +1 -0
  12. package/dist/esm/orderbook-depth.js +51 -0
  13. package/dist/esm/orderbook-depth.js.map +1 -0
  14. package/dist/esm/orderbook-widget.js +792 -0
  15. package/dist/esm/orderbook-widget.js.map +1 -0
  16. package/dist/esm/positions-widget.js +9 -0
  17. package/dist/esm/positions-widget.js.map +1 -1
  18. package/dist/esm/trade-history-widget.js +8 -0
  19. package/dist/esm/trade-history-widget.js.map +1 -1
  20. package/dist/esm/tradefeed-aggregator.js +58 -0
  21. package/dist/esm/tradefeed-aggregator.js.map +1 -0
  22. package/dist/esm/tradefeed-bubbles.js +131 -0
  23. package/dist/esm/tradefeed-bubbles.js.map +1 -0
  24. package/dist/esm/tradefeed-widget.js +692 -0
  25. package/dist/esm/tradefeed-widget.js.map +1 -0
  26. package/dist/esm/trading-host.js +2 -0
  27. package/dist/esm/trading-host.js.map +1 -1
  28. package/dist/esm/watchlist-widget.js +2 -0
  29. package/dist/esm/watchlist-widget.js.map +1 -1
  30. package/dist/sstradingcontrols.js +3346 -25
  31. package/dist/sstradingcontrols.js.map +3 -3
  32. package/dist/types/active-orders-widget.d.ts.map +1 -1
  33. package/dist/types/control-types.d.ts +3 -0
  34. package/dist/types/control-types.d.ts.map +1 -1
  35. package/dist/types/grid-menu.d.ts +4 -0
  36. package/dist/types/grid-menu.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +13 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/order-entry-widget.d.ts +86 -0
  40. package/dist/types/order-entry-widget.d.ts.map +1 -0
  41. package/dist/types/orderbook-depth.d.ts +19 -0
  42. package/dist/types/orderbook-depth.d.ts.map +1 -0
  43. package/dist/types/orderbook-widget.d.ts +105 -0
  44. package/dist/types/orderbook-widget.d.ts.map +1 -0
  45. package/dist/types/positions-widget.d.ts.map +1 -1
  46. package/dist/types/trade-history-widget.d.ts.map +1 -1
  47. package/dist/types/tradefeed-aggregator.d.ts +15 -0
  48. package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
  49. package/dist/types/tradefeed-bubbles.d.ts +39 -0
  50. package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
  51. package/dist/types/tradefeed-widget.d.ts +78 -0
  52. package/dist/types/tradefeed-widget.d.ts.map +1 -0
  53. package/dist/types/trading-data.d.ts +22 -0
  54. package/dist/types/trading-data.d.ts.map +1 -1
  55. package/dist/types/trading-host.d.ts +8 -0
  56. package/dist/types/trading-host.d.ts.map +1 -1
  57. package/dist/types/watchlist-widget.d.ts.map +1 -1
  58. package/package.json +21 -2
  59. package/screenshots/panels.jpg +0 -0
  60. package/src/active-orders-widget.ts +9 -0
  61. package/src/control-types.ts +3 -0
  62. package/src/grid-menu.ts +67 -0
  63. package/src/index.ts +31 -0
  64. package/src/order-entry-widget.ts +733 -0
  65. package/src/orderbook-depth.ts +99 -0
  66. package/src/orderbook-widget.ts +896 -0
  67. package/src/positions-widget.ts +9 -0
  68. package/src/trade-history-widget.ts +8 -0
  69. package/src/tradefeed-aggregator.ts +95 -0
  70. package/src/tradefeed-bubbles.ts +194 -0
  71. package/src/tradefeed-widget.ts +744 -0
  72. package/src/trading-data.ts +168 -121
  73. package/src/trading-host.ts +326 -300
  74. package/src/watchlist-widget.ts +2 -0
  75. package/styles/theme.css +23 -0
  76. package/styles/trading-controls.css +911 -0
  77. package/translation-keys.json +103 -2
@@ -0,0 +1,792 @@
1
+ // Order book — multi-instance.
2
+ //
3
+ // Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
4
+ // the page, so it can be constructed by any host that supplies a `TradingHost`.
5
+ // Every instance keeps its own symbol, depth, row layout and side order, and a
6
+ // page may hold as many ladders as it has room for.
7
+ //
8
+ // The ladder is not a table and so not a `DataGrid`: a level is a row of three
9
+ // figures behind a proportional volume bar and a heat tint, both of which are
10
+ // measured per level. The measurements are handed to the stylesheet as custom
11
+ // properties (`--t-ob-bar`, `--t-ob-heat`, `--t-ob-sent`) so the widths and the
12
+ // colours stay in CSS, where every other look in this package lives. The one
13
+ // exception is the depth chart above the ladder: it is drawn on a canvas, which
14
+ // no class name can reach, so its colours come from
15
+ // `host.presentation.canvasPalette()` and its geometry from `orderbook-depth.ts`.
16
+ //
17
+ // Where the data comes from: the host pushes frames in through `applyFrame`,
18
+ // one per server frame, fanned to every live instance — which is why each
19
+ // instance filters by its own symbol, and why `host.register(this)` at the end
20
+ // of the constructor is what makes a ladder update at all. The first frame of a
21
+ // (re)subscription is a snapshot and replaces the ledger; the rest are diffs
22
+ // (`quantity: 0` deletes a level) carrying a per-symbol sequence. A gap in that
23
+ // sequence means a frame was missed and the diffs after it cannot be trusted,
24
+ // so the ladder asks the market-data client to resend the symbol from scratch
25
+ // rather than drifting. Own resting orders come from
26
+ // `marketData.getOrders()` — the badge is an overlay on the levels, not a
27
+ // second subscription.
28
+ import { formatPrice, formatQty } from './formatters.js';
29
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
30
+ import { ControlTypes } from './control-types.js';
31
+ import { MarketDataLevels, assertHost } from './trading-host.js';
32
+ import { depthPolyline, depthSide } from './orderbook-depth.js';
33
+ // StockSharp's OrderStates.Active. Only a resting order sits on the book, so
34
+ // only a resting order earns the "your size is here" badge. The full enum is
35
+ // declared once in `active-orders-widget.ts`; importing it here would pull that
36
+ // blotter — and the grid it renders with — into every bundle that wanted a
37
+ // ladder and nothing else.
38
+ const RESTING_STATE = 3;
39
+ // Heat tint per level, as the percentage of the direction colour mixed into the
40
+ // row background. Faint at the shallowest level and still faint at the largest,
41
+ // because the volume bar behind the row is what carries the size — the tint
42
+ // only separates a wall from its neighbours.
43
+ const HEAT_FLOOR_PCT = 6;
44
+ const HEAT_RANGE_PCT = 18;
45
+ export class OrderBookWidget {
46
+ static create(hostEl, state, deps) {
47
+ // Assert before building: the markup below is localized through the
48
+ // host, so a missing host has to fail here rather than render a panel
49
+ // captioned with raw English keys.
50
+ const host = assertHost(deps?.host, 'OrderBookWidget');
51
+ const root = OrderBookWidget._buildRoot(host);
52
+ root.id = makePanelId(OrderBookWidget.TYPE);
53
+ hostEl.appendChild(root);
54
+ return new OrderBookWidget(root, state || {}, deps);
55
+ }
56
+ // The panel's markup. The host stylesheet reads this structure, and a
57
+ // docking host lifts `.panel-header`'s children into its tab strip — which
58
+ // is why the header holds every control the ladder has.
59
+ //
60
+ // The `+` is built here rather than left out: the port documents `spawn` as
61
+ // this control's gesture, and a button nothing renders is a capability
62
+ // nobody can reach.
63
+ static _buildRoot(host) {
64
+ const title = host.t('OrderBook');
65
+ return makePanelRoot('orderbook-panel', title, [
66
+ makeElement('div', 'panel-header', {}, [
67
+ makeElement('span', 'ob-symbol-label', { title: host.t('ClickToChangeSymbol'), role: 'button', tabindex: '0' }, ['--']),
68
+ makeElement('div', 'ob-depth-selector', { role: 'group', 'aria-label': host.t('OrderBookDepth') }, OrderBookWidget.DEPTHS.map(depth => makeElement('button', 'btn-ob-depth', {
69
+ type: 'button',
70
+ 'data-depth': String(depth),
71
+ 'aria-label': host.t('LevelsCount', depth),
72
+ 'aria-pressed': 'false',
73
+ }, [String(depth)]))),
74
+ makeElement('div', 'ob-view-selector', { role: 'group', 'aria-label': host.t('OrderBookView') }, [
75
+ makeIconButton('btn-ob-view', host.t('OrderBookViewDiagonal'), 'bi-distribute-horizontal', { type: 'button', 'data-view': 'diagonal', 'aria-pressed': 'false' }),
76
+ makeIconButton('btn-ob-view', host.t('OrderBookViewStacked'), 'bi-table', { type: 'button', 'data-view': 'stacked', 'aria-pressed': 'false' }),
77
+ ]),
78
+ makeIconButton('btn-ob-invert', host.t('OrderBookInvertSides'), 'bi-arrow-down-up', { type: 'button', 'aria-pressed': 'false' }),
79
+ makeIconButton('btn-ob-depthtoggle', host.t('ToggleDepthChart'), 'bi-graph-up', { type: 'button', 'aria-pressed': 'false' }),
80
+ makeElement('span', 'spread-label', { 'aria-live': 'polite' }, ['--']),
81
+ makeIconButton('bt-icon-btn ob-add-btn', host.t('AddOrderbook'), 'bi-plus-lg', { type: 'button' }),
82
+ makeIconButton('bt-icon-btn bt-icon-cancel ob-close-btn', host.t('RemoveOrderbook'), 'bi-x', { type: 'button' }),
83
+ ]),
84
+ makeElement('canvas', 'ob-depth-chart', { 'aria-hidden': 'true' }, []),
85
+ makeElement('div', 'ob-col-headers', { 'aria-hidden': 'true' }, [
86
+ makeElement('span', 'ob-col-price', {}, [host.t('Price')]),
87
+ makeElement('span', 'ob-col-qty', {}, [host.t('Qty')]),
88
+ makeElement('span', 'ob-col-total', {}, [host.t('Total')]),
89
+ ]),
90
+ makeElement('div', 'orderbook-content', { 'aria-live': 'polite', 'aria-atomic': 'false' }, [
91
+ makeElement('div', 'orderbook-asks', { 'aria-label': host.t('AskOrders') }, []),
92
+ makeElement('div', 'orderbook-mid', { 'aria-label': host.t('MidPrice') }, [
93
+ makeElement('span', 'ob-mid-price', {}, ['--']),
94
+ makeElement('span', 'ob-mid-spread', { 'aria-live': 'polite' }, []),
95
+ ]),
96
+ makeElement('div', 'orderbook-bids', { 'aria-label': host.t('BidOrders') }, []),
97
+ ]),
98
+ makeElement('div', 'ob-sentiment', { 'aria-label': host.t('OrderBookSentiment') }, [
99
+ makeElement('div', 'ob-sent-bid', {}, [
100
+ makeElement('span', 'ob-sent-label', {}, [host.t('BidShort')]),
101
+ makeElement('span', 'ob-sent-bid-pct', {}, ['--']),
102
+ ]),
103
+ makeElement('div', 'ob-sent-ask', {}, [
104
+ makeElement('span', 'ob-sent-ask-pct', {}, ['--']),
105
+ makeElement('span', 'ob-sent-label', {}, [host.t('AskShort')]),
106
+ ]),
107
+ ]),
108
+ ]);
109
+ }
110
+ constructor(rootEl, state, deps) {
111
+ this._host = assertHost(deps?.host, 'OrderBookWidget');
112
+ for (const name of ['onPriceSelected', 'onPriceExecuted', 'maxDepth', 'pixelRatio']) {
113
+ if (typeof deps?.[name] !== 'function')
114
+ throw new Error(`OrderBookWidget: dep "${name}" is required`);
115
+ }
116
+ this.rootEl = rootEl;
117
+ this._deps = deps;
118
+ this.asksEl = this.rootEl.querySelector('.orderbook-asks');
119
+ this.bidsEl = this.rootEl.querySelector('.orderbook-bids');
120
+ this.midEl = this.rootEl.querySelector('.ob-mid-price');
121
+ this.spreadEl = this.rootEl.querySelector('.spread-label');
122
+ this.contentEl = this.rootEl.querySelector('.orderbook-content');
123
+ this.depthChartEl = this.rootEl.querySelector('.ob-depth-chart');
124
+ this._midSpreadEl = this.rootEl.querySelector('.ob-mid-spread');
125
+ this._sentimentEl = this.rootEl.querySelector('.ob-sentiment');
126
+ this._sentBidPctEl = this.rootEl.querySelector('.ob-sent-bid-pct');
127
+ this._sentAskPctEl = this.rootEl.querySelector('.ob-sent-ask-pct');
128
+ this._symbolLabel = this.rootEl.querySelector('.ob-symbol-label');
129
+ this._depthBtns = Array.from(this.rootEl.querySelectorAll('.btn-ob-depth'));
130
+ this._viewBtns = Array.from(this.rootEl.querySelectorAll('.btn-ob-view'));
131
+ this._invertBtn = this.rootEl.querySelector('.btn-ob-invert');
132
+ this._depthToggleBtn = this.rootEl.querySelector('.btn-ob-depthtoggle');
133
+ this._addBtn = this.rootEl.querySelector('.ob-add-btn');
134
+ this._closeBtn = this.rootEl.querySelector('.ob-close-btn');
135
+ // Per-panel state is whatever the host persisted, so every value is read
136
+ // defensively and falls back to this control's own default.
137
+ const saved = state;
138
+ this._prevQuantities = new Map();
139
+ this._bidsByPrice = new Map();
140
+ this._asksByPrice = new Map();
141
+ this._lastSequence = 0;
142
+ this._currentSymbol = null;
143
+ this._followsActive = saved.followsActive === true;
144
+ this._view = saved.view === 'diagonal' ? 'diagonal' : 'stacked';
145
+ this._invertSides = saved.invertSides === true;
146
+ // On by default; only an explicit false hides it.
147
+ this._showDepthChart = saved.showDepthChart !== false;
148
+ let depth = OrderBookWidget._isDepth(saved.depth) ? saved.depth : OrderBookWidget._deepest();
149
+ // The follows-active instance restores the settings the user chose the
150
+ // last time, which live in the host's preferences rather than in this
151
+ // panel's state — there is one of it per page and it outlives any
152
+ // particular layout. Pinned instances carry theirs in `state` above.
153
+ if (this._followsActive) {
154
+ const preferences = this._host.preferences;
155
+ const savedDepth = Number(preferences.get(OrderBookWidget.DEPTH_KEY, null));
156
+ if (OrderBookWidget._isDepth(savedDepth))
157
+ depth = savedDepth;
158
+ const savedView = preferences.get(OrderBookWidget.VIEW_KEY, null);
159
+ if (savedView === 'diagonal' || savedView === 'stacked')
160
+ this._view = savedView;
161
+ const savedInvert = preferences.get(OrderBookWidget.INVERT_KEY, null);
162
+ if (savedInvert !== null)
163
+ this._invertSides = savedInvert === 'true';
164
+ const savedDepthChart = preferences.get(OrderBookWidget.DEPTHCHART_KEY, null);
165
+ if (savedDepthChart !== null)
166
+ this._showDepthChart = savedDepthChart === 'true';
167
+ }
168
+ this._depth = this._fitDepth(depth);
169
+ this._wireHeader();
170
+ this._wireBookClicks();
171
+ this._refreshDepthButtons();
172
+ this._applyViewState();
173
+ this._refreshViewButtons();
174
+ this._refreshInvertButton();
175
+ this._refreshDepthToggleButton();
176
+ // A follows-active instance ignores any persisted symbol: it inherits
177
+ // the live one from the host on boot, and honouring a stale one here
178
+ // would flash a previous session's instrument until that arrives.
179
+ if (saved.symbol && !this._followsActive)
180
+ this.setSymbol(saved.symbol);
181
+ // Self-register — this is how incoming frames find their way here, and
182
+ // an unregistered ladder is one that never updates.
183
+ this._host.register(this);
184
+ }
185
+ /// Release the market-data subscription, leave the fan-out and remove the
186
+ /// panel. Called by the host when the panel is destroyed.
187
+ dispose() {
188
+ if (this._currentSymbol)
189
+ void this._host.trading.marketData.removeSymbol(this._currentSymbol).catch(() => { });
190
+ this._host.unregister(this);
191
+ try {
192
+ this.rootEl.remove();
193
+ }
194
+ catch { /* already detached */ }
195
+ }
196
+ /// Switch to a new instrument: drop the old subscription, take a new one and
197
+ /// blank the ledger, because a diff for the old symbol must never be applied
198
+ /// to the new one.
199
+ setSymbol(symbol) {
200
+ if (!symbol)
201
+ return;
202
+ const next = String(symbol).toUpperCase();
203
+ if (next === this._currentSymbol)
204
+ return;
205
+ const previous = this._currentSymbol;
206
+ this._currentSymbol = next;
207
+ this._resetLedger();
208
+ if (this._symbolLabel)
209
+ this._symbolLabel.textContent = next;
210
+ this._render();
211
+ // addSymbol / removeSymbol are refcounted by the client, so two ladders
212
+ // on one symbol hold one subscription and neither can drop the other's.
213
+ const marketData = this._host.trading.marketData;
214
+ if (previous)
215
+ void marketData.removeSymbol(previous).catch(() => { });
216
+ // A ladder needs the depth, and the header reads best bid/ask, so it
217
+ // asks for the whole set rather than for quotes alone.
218
+ void marketData.addSymbol(next, MarketDataLevels.Full).catch(() => { });
219
+ // A pinned instance remembers its instrument so a reload restores this
220
+ // exact ladder; the follows-active one deliberately does not, because it
221
+ // tracks whatever the host is showing.
222
+ if (!this._followsActive)
223
+ this._persistState({ symbol: next });
224
+ }
225
+ getSymbol() { return this._currentSymbol; }
226
+ getDepth() { return this._depth; }
227
+ /// True for the instance that follows the host's active symbol. A page has
228
+ /// at most one, and a host that feeds a best-bid/ask from a ladder asks for
229
+ /// it by this.
230
+ isFollowsActive() { return this._followsActive; }
231
+ /// The ledger as levels, best price first. Public because a host reads the
232
+ /// touch off the ladder rather than keeping a second copy of the book.
233
+ getBids() {
234
+ return OrderBookWidget._sorted(this._bidsByPrice, -1);
235
+ }
236
+ getAsks() {
237
+ return OrderBookWidget._sorted(this._asksByPrice, 1);
238
+ }
239
+ // The three setters below repaint BEFORE they persist: `_persistState` ends
240
+ // in a layout flush and the host serializes the live panel to produce it, so
241
+ // persisting first would write out the panel as it looked before the change
242
+ // the user just made.
243
+ setDepth(depth) {
244
+ if (!OrderBookWidget._isDepth(depth))
245
+ return;
246
+ this._depth = this._fitDepth(depth);
247
+ this._refreshDepthButtons();
248
+ this._render();
249
+ if (this._followsActive)
250
+ this._host.preferences.set(OrderBookWidget.DEPTH_KEY, String(this._depth));
251
+ else
252
+ this._persistState({ depth: this._depth });
253
+ }
254
+ setView(view) {
255
+ if (view !== 'diagonal' && view !== 'stacked')
256
+ return;
257
+ this._view = view;
258
+ this._applyViewState();
259
+ this._refreshViewButtons();
260
+ if (this._followsActive)
261
+ this._host.preferences.set(OrderBookWidget.VIEW_KEY, view);
262
+ else
263
+ this._persistState({ view });
264
+ }
265
+ /// Put the bid block above the mid line and the ask block below it, the
266
+ /// reverse of the convention every Western exchange UI follows.
267
+ setInvertSides(invert) {
268
+ this._invertSides = invert === true;
269
+ this._applyViewState();
270
+ this._refreshInvertButton();
271
+ if (this._followsActive)
272
+ this._host.preferences.set(OrderBookWidget.INVERT_KEY, this._invertSides ? 'true' : 'false');
273
+ else
274
+ this._persistState({ invertSides: this._invertSides });
275
+ }
276
+ /// Show or hide the depth chart above the ladder. The canvas keeps its last
277
+ /// bitmap while hidden, so showing it again brings the chart straight back
278
+ /// and the next frame repaints it.
279
+ setShowDepthChart(show) {
280
+ this._showDepthChart = show === true;
281
+ this._applyViewState();
282
+ this._refreshDepthToggleButton();
283
+ if (this._followsActive)
284
+ this._host.preferences.set(OrderBookWidget.DEPTHCHART_KEY, this._showDepthChart ? 'true' : 'false');
285
+ this._persistState({ showDepthChart: this._showDepthChart });
286
+ }
287
+ /// Apply one frame. A snapshot replaces the ledger; a diff merges into it,
288
+ /// with `quantity: 0` deleting a level. A break in the sequence means a
289
+ /// frame was missed, so the ladder asks for a fresh snapshot instead of
290
+ /// applying a diff to state it can no longer trust.
291
+ ///
292
+ /// Every instance receives every frame, so the symbol filter here is what
293
+ /// keeps a frame for one instrument out of a ladder watching another.
294
+ applyFrame(frame) {
295
+ if (!frame || !frame.symbol)
296
+ return;
297
+ if (this._currentSymbol && frame.symbol !== this._currentSymbol)
298
+ return;
299
+ if (this._currentSymbol !== frame.symbol) {
300
+ this._currentSymbol = frame.symbol;
301
+ // The follows-active instance can receive its first frame before the
302
+ // host calls setSymbol, whose early return on an unchanged symbol
303
+ // would then skip the label.
304
+ if (this._symbolLabel)
305
+ this._symbolLabel.textContent = frame.symbol;
306
+ }
307
+ const sequence = frame.sequence ?? 0;
308
+ if (frame.isSnapshot) {
309
+ this._resetLedger();
310
+ this._applySnapshotSide(this._bidsByPrice, frame.bids, 'bid', frame.symbol, sequence);
311
+ this._applySnapshotSide(this._asksByPrice, frame.asks, 'ask', frame.symbol, sequence);
312
+ this._lastSequence = sequence;
313
+ }
314
+ else {
315
+ if (this._lastSequence !== 0 && sequence !== this._lastSequence + 1) {
316
+ this._anomaly(`sequence gap: expected ${this._lastSequence + 1}, got ${sequence} sym=${frame.symbol} — requesting snapshot`);
317
+ this._lastSequence = 0;
318
+ void this._requestResubscribe(frame.symbol);
319
+ return;
320
+ }
321
+ this._applyDiffSide(this._bidsByPrice, frame.bids, 'bid', frame.symbol, sequence);
322
+ this._applyDiffSide(this._asksByPrice, frame.asks, 'ask', frame.symbol, sequence);
323
+ this._lastSequence = sequence || this._lastSequence;
324
+ }
325
+ this._validateBookCross(frame.symbol, sequence, frame.isSnapshot === true);
326
+ this._render();
327
+ }
328
+ _wireHeader() {
329
+ for (const button of this._depthBtns) {
330
+ button.addEventListener('click', (e) => {
331
+ e.preventDefault();
332
+ this.setDepth(Number(button.dataset.depth));
333
+ });
334
+ }
335
+ for (const button of this._viewBtns) {
336
+ button.addEventListener('click', (e) => {
337
+ e.preventDefault();
338
+ const view = button.dataset.view;
339
+ if (view === 'diagonal' || view === 'stacked')
340
+ this.setView(view);
341
+ });
342
+ }
343
+ this._invertBtn?.addEventListener('click', (e) => {
344
+ e.preventDefault();
345
+ this.setInvertSides(!this._invertSides);
346
+ });
347
+ this._depthToggleBtn?.addEventListener('click', (e) => {
348
+ e.preventDefault();
349
+ this.setShowDepthChart(!this._showDepthChart);
350
+ });
351
+ // Another ladder, starting where this one is — the most natural copy
352
+ // gesture, and the host decides where it goes.
353
+ this._addBtn?.addEventListener('click', (e) => {
354
+ e.preventDefault();
355
+ e.stopPropagation();
356
+ this._host.spawn({
357
+ symbol: this._currentSymbol,
358
+ depth: this._depth,
359
+ view: this._view,
360
+ invertSides: this._invertSides,
361
+ showDepthChart: this._showDepthChart,
362
+ followsActive: false,
363
+ });
364
+ });
365
+ this._closeBtn?.addEventListener('click', (e) => {
366
+ e.preventDefault();
367
+ e.stopPropagation();
368
+ this._host.close();
369
+ });
370
+ // Picking an instrument retargets THIS ladder only — a user who wanted
371
+ // the whole page to follow would have used the host's own instrument
372
+ // selector. Picking on the follows-active instance detaches it: it
373
+ // becomes a pinned ladder on the chosen symbol, identical to any other.
374
+ this._symbolLabel?.addEventListener('click', (e) => {
375
+ e.preventDefault();
376
+ e.stopPropagation();
377
+ this._host.trading.pickInstrument((symbol) => {
378
+ if (this._followsActive) {
379
+ this._followsActive = false;
380
+ this._persistState({ followsActive: false });
381
+ }
382
+ this.setSymbol(symbol);
383
+ });
384
+ });
385
+ }
386
+ // A click anywhere on a level, caught once on the container: the rows are
387
+ // rebuilt on every frame, so a listener per row would be re-bound dozens of
388
+ // times a second.
389
+ //
390
+ // Plain click prefills an order at that price and leaves the sending to the
391
+ // user. Ctrl/Cmd click sends at once — a single click doing that is how a
392
+ // sneeze on the bid becomes a fill.
393
+ _wireBookClicks() {
394
+ this.contentEl?.addEventListener('click', (e) => {
395
+ const target = e.target;
396
+ const row = target?.closest('.ob-row');
397
+ if (!row)
398
+ return;
399
+ const price = Number(row.dataset.price);
400
+ if (!Number.isFinite(price))
401
+ return;
402
+ // A bid row is where the user sells and an ask row is where they
403
+ // buy, so the row carries the side of the order, not of the level.
404
+ const side = row.dataset.side === 'buy' ? 0 : 1;
405
+ if (e.ctrlKey || e.metaKey)
406
+ this._deps.onPriceExecuted(price, side);
407
+ else
408
+ this._deps.onPriceSelected(price, side);
409
+ });
410
+ }
411
+ _refreshDepthButtons() {
412
+ for (const button of this._depthBtns) {
413
+ const on = Number(button.dataset.depth) === this._depth;
414
+ button.classList.toggle('active', on);
415
+ button.setAttribute('aria-pressed', on ? 'true' : 'false');
416
+ }
417
+ }
418
+ _refreshViewButtons() {
419
+ for (const button of this._viewBtns) {
420
+ const on = button.dataset.view === this._view;
421
+ button.classList.toggle('active', on);
422
+ button.setAttribute('aria-pressed', on ? 'true' : 'false');
423
+ }
424
+ }
425
+ _refreshInvertButton() {
426
+ this._invertBtn?.classList.toggle('active', this._invertSides);
427
+ this._invertBtn?.setAttribute('aria-pressed', this._invertSides ? 'true' : 'false');
428
+ }
429
+ _refreshDepthToggleButton() {
430
+ this._depthToggleBtn?.classList.toggle('active', this._showDepthChart);
431
+ this._depthToggleBtn?.setAttribute('aria-pressed', this._showDepthChart ? 'true' : 'false');
432
+ }
433
+ // The variant classes on the panel root the stylesheet keys the two layouts,
434
+ // the side order and the depth chart off.
435
+ _applyViewState() {
436
+ this.rootEl.classList.toggle('ob-view-stacked', this._view === 'stacked');
437
+ this.rootEl.classList.toggle('ob-view-diagonal', this._view === 'diagonal');
438
+ this.rootEl.classList.toggle('ob-invert', this._invertSides);
439
+ this.rootEl.classList.toggle('ob-hide-depth', !this._showDepthChart);
440
+ }
441
+ // Record what this panel remembers, then ask the host to flush the layout.
442
+ // Two calls because they are two decisions, and every caller here is a
443
+ // setting the user just changed by hand — so it is worth flushing.
444
+ _persistState(patch) {
445
+ this._host.persistState(patch);
446
+ this._host.saveLayout();
447
+ }
448
+ _resetLedger() {
449
+ this._bidsByPrice.clear();
450
+ this._asksByPrice.clear();
451
+ this._prevQuantities.clear();
452
+ this._lastSequence = 0;
453
+ }
454
+ // The host answers how many levels it has room for, so a phone-sized page
455
+ // narrows the ladder without this control knowing what a viewport is.
456
+ _fitDepth(depth) {
457
+ const room = Number(this._deps.maxDepth());
458
+ if (!Number.isFinite(room) || room < 1)
459
+ return depth;
460
+ return Math.min(depth, room);
461
+ }
462
+ static _isDepth(depth) {
463
+ return typeof depth === 'number' && OrderBookWidget.DEPTHS.includes(depth);
464
+ }
465
+ static _deepest() {
466
+ return OrderBookWidget.DEPTHS[OrderBookWidget.DEPTHS.length - 1];
467
+ }
468
+ static _sorted(ledger, direction) {
469
+ return [...ledger.entries()]
470
+ .filter(([price, quantity]) => Number.isFinite(price) && quantity > 0)
471
+ .sort((left, right) => direction * (left[0] - right[0]))
472
+ .map(([price, quantity]) => ({ price, quantity }));
473
+ }
474
+ _applySnapshotSide(ledger, levels, side, symbol, sequence) {
475
+ for (const level of levels || []) {
476
+ const quantity = level.quantity ?? 0;
477
+ if (level.price == null || !Number.isFinite(level.price) || !(quantity > 0)) {
478
+ this._anomaly(`snapshot ${side} level dropped: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence}`);
479
+ continue;
480
+ }
481
+ ledger.set(level.price, quantity);
482
+ }
483
+ }
484
+ _applyDiffSide(ledger, levels, side, symbol, sequence) {
485
+ for (const level of levels || []) {
486
+ // A level with a non-finite price is wire corruption: applying it
487
+ // lands a NaN key in the ledger, which sorts unpredictably and
488
+ // renders as a phantom row between two real levels.
489
+ if (level.price == null || !Number.isFinite(level.price)) {
490
+ this._anomaly(`invalid ${side} price: price=${level.price} qty=${level.quantity} sym=${symbol} seq=${sequence} — ignoring`);
491
+ continue;
492
+ }
493
+ const quantity = level.quantity ?? 0;
494
+ if (quantity < 0) {
495
+ this._anomaly(`negative ${side} qty: price=${level.price} qty=${quantity} sym=${symbol} seq=${sequence} — ignoring`);
496
+ continue;
497
+ }
498
+ if (quantity === 0) {
499
+ if (!ledger.has(level.price)) {
500
+ this._anomaly(`delete missing ${side}: price=${level.price} sym=${symbol} seq=${sequence}`);
501
+ continue;
502
+ }
503
+ ledger.delete(level.price);
504
+ }
505
+ else {
506
+ ledger.set(level.price, quantity);
507
+ }
508
+ }
509
+ }
510
+ // Best bid below best ask, or something upstream is broken — usually a diff
511
+ // applied to stale state after a gap the sequence check did not catch.
512
+ _validateBookCross(symbol, sequence, wasSnapshot) {
513
+ if (this._bidsByPrice.size === 0 || this._asksByPrice.size === 0)
514
+ return;
515
+ let bestBid = -Infinity;
516
+ let bestAsk = Infinity;
517
+ for (const price of this._bidsByPrice.keys())
518
+ if (price > bestBid)
519
+ bestBid = price;
520
+ for (const price of this._asksByPrice.keys())
521
+ if (price < bestAsk)
522
+ bestAsk = price;
523
+ if (bestBid >= bestAsk) {
524
+ this._anomaly(`crossed book: bid=${bestBid} >= ask=${bestAsk} sym=${symbol} seq=${sequence} ${wasSnapshot ? 'snapshot' : 'diff'}`);
525
+ }
526
+ }
527
+ // Ask for the symbol from scratch. Idempotent as far as the user is
528
+ // concerned: the ladder blanks for a frame and refills.
529
+ async _requestResubscribe(symbol) {
530
+ try {
531
+ await this._host.trading.marketData.resubscribe(symbol, MarketDataLevels.Full);
532
+ }
533
+ catch (err) {
534
+ this._anomaly(`resubscribe after a gap failed for ${symbol}: ${err}`);
535
+ }
536
+ }
537
+ // Wire trouble the user cannot see and support has to, through the port.
538
+ _anomaly(message) {
539
+ this._host.log(`OrderBookWidget: ${message}`);
540
+ }
541
+ _render() {
542
+ this._paint(this.getBids(), this.getAsks());
543
+ }
544
+ _paint(bids, asks) {
545
+ if (!this.asksEl || !this.bidsEl)
546
+ return;
547
+ const visibleAsks = asks.slice(0, this._depth);
548
+ const visibleBids = bids.slice(0, this._depth);
549
+ // The bar is scaled per side — a 100k bid wall must not flatten a normal
550
+ // 200-lot ask ladder on the other half — while the heat tint is scaled
551
+ // across both, so the two halves stay perceptually comparable.
552
+ const maxBid = Math.max(...visibleBids.map(level => level.quantity), 1);
553
+ const maxAsk = Math.max(...visibleAsks.map(level => level.quantity), 1);
554
+ const maxBoth = Math.max(maxBid, maxAsk, 1);
555
+ const own = this._ownQuantities();
556
+ const painted = new Map();
557
+ // Asks are rendered worst-first so the best ask ends up next to the mid
558
+ // line, while the cumulative total runs the other way — the best ask
559
+ // reads `qty == total` and the sum grows as the eye moves away from the
560
+ // spread.
561
+ const askTotals = [];
562
+ let running = 0;
563
+ for (const level of visibleAsks) {
564
+ running += level.quantity;
565
+ askTotals.push(running);
566
+ }
567
+ const askRows = [];
568
+ for (let i = visibleAsks.length - 1; i >= 0; i--) {
569
+ askRows.push(this._levelRow(visibleAsks[i], {
570
+ rowClass: 'ob-row ask',
571
+ orderSide: 'buy',
572
+ key: `a_${visibleAsks[i].price}`,
573
+ cumulative: askTotals[i],
574
+ barPct: (visibleAsks[i].quantity / maxAsk) * 100,
575
+ heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (visibleAsks[i].quantity / maxBoth),
576
+ // More size on the offer is pressure downward, so a growing ask
577
+ // level flashes in the falling colour.
578
+ growthClass: 'flash-red',
579
+ shrinkClass: 'flash-green',
580
+ ownQuantity: own.asks.get(OrderBookWidget._priceKey(visibleAsks[i].price)),
581
+ painted,
582
+ }));
583
+ }
584
+ this.asksEl.replaceChildren(...askRows);
585
+ running = 0;
586
+ const bidRows = [];
587
+ for (const level of visibleBids) {
588
+ running += level.quantity;
589
+ bidRows.push(this._levelRow(level, {
590
+ rowClass: 'ob-row bid',
591
+ orderSide: 'sell',
592
+ key: `b_${level.price}`,
593
+ cumulative: running,
594
+ barPct: (level.quantity / maxBid) * 100,
595
+ heatPct: HEAT_FLOOR_PCT + HEAT_RANGE_PCT * (level.quantity / maxBoth),
596
+ growthClass: 'flash-green',
597
+ shrinkClass: 'flash-red',
598
+ ownQuantity: own.bids.get(OrderBookWidget._priceKey(level.price)),
599
+ painted,
600
+ }));
601
+ }
602
+ this.bidsEl.replaceChildren(...bidRows);
603
+ this._prevQuantities = painted;
604
+ if (asks.length > 0 && bids.length > 0) {
605
+ const bestAsk = asks[0].price;
606
+ const bestBid = bids[0].price;
607
+ const spread = bestAsk - bestBid;
608
+ if (this.midEl)
609
+ this.midEl.textContent = formatPrice((bestAsk + bestBid) / 2);
610
+ if (this._midSpreadEl)
611
+ this._midSpreadEl.textContent = formatPrice(spread);
612
+ if (this.spreadEl)
613
+ this.spreadEl.textContent = this._host.t('Spread: {0}', formatPrice(spread));
614
+ }
615
+ this._paintSentiment(visibleBids, visibleAsks);
616
+ this._paintDepthChart(visibleBids, visibleAsks);
617
+ }
618
+ // One level. Every measurement it carries — the bar's share of its side, the
619
+ // heat tint — reaches the stylesheet as a custom property, so the widths and
620
+ // the colours are still declared in CSS.
621
+ _levelRow(level, options) {
622
+ const quantity = makeElement('span', 'qty', {}, [formatQty(level.quantity)]);
623
+ if (options.ownQuantity) {
624
+ quantity.appendChild(makeElement('span', 'own-badge', { title: this._host.t('YourOrder') }, [formatQty(options.ownQuantity)]));
625
+ }
626
+ const row = makeElement('div', options.rowClass, {
627
+ 'data-side': options.orderSide,
628
+ 'data-price': String(level.price),
629
+ }, [
630
+ makeElement('span', 'price', {}, [formatPrice(level.price)]),
631
+ quantity,
632
+ makeElement('span', 'total', {}, [formatQty(options.cumulative)]),
633
+ makeElement('span', 'bar', {}, []),
634
+ ]);
635
+ row.style.setProperty('--t-ob-bar', `${options.barPct.toFixed(0)}%`);
636
+ row.style.setProperty('--t-ob-heat', `${options.heatPct.toFixed(1)}%`);
637
+ if (options.ownQuantity)
638
+ row.classList.add('own');
639
+ const previous = this._prevQuantities.get(options.key);
640
+ if (previous !== undefined && previous !== level.quantity)
641
+ row.classList.add(level.quantity > previous ? options.growthClass : options.shrinkClass);
642
+ options.painted.set(options.key, level.quantity);
643
+ return row;
644
+ }
645
+ // This session's resting orders on this symbol, summed per price — the "your
646
+ // size is here" badge is an overlay on the ledger, so it reads the client's
647
+ // own order cache rather than subscribing to anything.
648
+ //
649
+ // Prices are keyed as fixed-point text: two floats that print the same are
650
+ // the same level to a user, and `===` on them is not.
651
+ _ownQuantities() {
652
+ const bids = new Map();
653
+ const asks = new Map();
654
+ if (!this._currentSymbol)
655
+ return { bids, asks };
656
+ for (const order of this._host.trading.marketData.getOrders() || []) {
657
+ if (!order || order.instrument !== this._currentSymbol)
658
+ continue;
659
+ if (order.status !== RESTING_STATE)
660
+ continue;
661
+ if (order.limitPrice == null || order.side == null)
662
+ continue;
663
+ const side = this._host.presentation.isBuy(order.side) ? bids : asks;
664
+ const key = OrderBookWidget._priceKey(order.limitPrice);
665
+ const left = Number(order.balance ?? order.quantity) || 0;
666
+ side.set(key, (side.get(key) || 0) + left);
667
+ }
668
+ return { bids, asks };
669
+ }
670
+ static _priceKey(price) {
671
+ return Number(price).toFixed(8);
672
+ }
673
+ // The bid/ask split under the ladder, as one measurement the stylesheet
674
+ // turns into two widths.
675
+ //
676
+ // It sums the VISIBLE levels only, so the figure moves when the user changes
677
+ // depth. A whole-book share needs the server to aggregate one, which the
678
+ // frame does not carry today.
679
+ _paintSentiment(bids, asks) {
680
+ if (!this._sentimentEl)
681
+ return;
682
+ const bidSum = bids.reduce((sum, level) => sum + level.quantity, 0);
683
+ const askSum = asks.reduce((sum, level) => sum + level.quantity, 0);
684
+ const total = bidSum + askSum;
685
+ if (total <= 0) {
686
+ this._sentimentEl.style.setProperty('--t-ob-sent', '50%');
687
+ if (this._sentBidPctEl)
688
+ this._sentBidPctEl.textContent = '--';
689
+ if (this._sentAskPctEl)
690
+ this._sentAskPctEl.textContent = '--';
691
+ return;
692
+ }
693
+ // Rounded so the two halves sum to exactly 100 — a strip reading 99% is
694
+ // read as a bug rather than as rounding.
695
+ const askPct = Math.round((askSum / total) * 100);
696
+ const bidPct = 100 - askPct;
697
+ this._sentimentEl.style.setProperty('--t-ob-sent', `${bidPct}%`);
698
+ if (this._sentBidPctEl)
699
+ this._sentBidPctEl.textContent = `${bidPct}%`;
700
+ if (this._sentAskPctEl)
701
+ this._sentAskPctEl.textContent = `${askPct}%`;
702
+ }
703
+ // The depth curve above the ladder. Everything shaped is in
704
+ // `orderbook-depth.ts`; what is left here is the canvas plumbing and the
705
+ // host's palette, because a canvas takes colours as values and a class name
706
+ // cannot reach one.
707
+ _paintDepthChart(bids, asks) {
708
+ const canvas = this.depthChartEl;
709
+ if (!canvas || !this._showDepthChart)
710
+ return;
711
+ const ratio = Number(this._deps.pixelRatio()) || 1;
712
+ const cssWidth = canvas.clientWidth || 0;
713
+ const cssHeight = canvas.clientHeight || 0;
714
+ if (cssWidth < 4 || cssHeight < 4)
715
+ return;
716
+ const width = Math.floor(cssWidth * ratio);
717
+ const height = Math.floor(cssHeight * ratio);
718
+ if (canvas.width !== width)
719
+ canvas.width = width;
720
+ if (canvas.height !== height)
721
+ canvas.height = height;
722
+ const ctx = canvas.getContext('2d');
723
+ if (!ctx)
724
+ return;
725
+ ctx.clearRect(0, 0, width, height);
726
+ if (bids.length === 0 && asks.length === 0)
727
+ return;
728
+ const pad = 2 * ratio;
729
+ const midX = Math.floor(width / 2);
730
+ const geometry = {
731
+ midX,
732
+ pad,
733
+ halfWidth: midX - pad,
734
+ baseY: height - pad,
735
+ innerHeight: height - pad * 2,
736
+ };
737
+ const bidSide = depthSide(bids, -1, geometry);
738
+ const askSide = depthSide(asks, 1, geometry);
739
+ const maxTotal = Math.max(bidSide ? bidSide.total : 0, askSide ? askSide.total : 0, 1);
740
+ const palette = this._host.presentation.canvasPalette();
741
+ if (bidSide)
742
+ this._strokeDepthSide(ctx, depthPolyline(bidSide, maxTotal, geometry), palette.up, geometry, ratio);
743
+ if (askSide)
744
+ this._strokeDepthSide(ctx, depthPolyline(askSide, maxTotal, geometry), palette.down, geometry, ratio);
745
+ // The mid line, so the two halves read as two sides of one price rather
746
+ // than as two charts.
747
+ ctx.globalAlpha = 0.4;
748
+ ctx.fillStyle = palette.grid;
749
+ ctx.fillRect(midX - Math.max(1, ratio / 2), 0, Math.max(1, ratio), height);
750
+ ctx.globalAlpha = 1;
751
+ }
752
+ // Area under the step curve, then the curve on top. Opacity is varied here
753
+ // rather than in the colour, so the host states one solid colour per
754
+ // direction and this decides how much of it a fill is worth.
755
+ _strokeDepthSide(ctx, line, color, geometry, ratio) {
756
+ if (line.length === 0)
757
+ return;
758
+ ctx.beginPath();
759
+ ctx.moveTo(line[0][0], geometry.baseY);
760
+ for (const [x, y] of line)
761
+ ctx.lineTo(x, y);
762
+ ctx.lineTo(line[line.length - 1][0], geometry.baseY);
763
+ ctx.closePath();
764
+ ctx.globalAlpha = 0.18;
765
+ ctx.fillStyle = color;
766
+ ctx.fill();
767
+ ctx.beginPath();
768
+ ctx.moveTo(line[0][0], line[0][1]);
769
+ for (let i = 1; i < line.length; i++)
770
+ ctx.lineTo(line[i][0], line[i][1]);
771
+ ctx.lineWidth = Math.max(1.5, 1.5 * ratio);
772
+ ctx.lineJoin = 'miter';
773
+ ctx.globalAlpha = 0.95;
774
+ ctx.strokeStyle = color;
775
+ ctx.stroke();
776
+ ctx.globalAlpha = 1;
777
+ }
778
+ }
779
+ OrderBookWidget.TYPE = ControlTypes.OrderBook;
780
+ /// The depths the ladder offers, in the order the header shows them.
781
+ OrderBookWidget.DEPTHS = [5, 10];
782
+ /// Persisted depth for the instance that follows the host's active symbol.
783
+ /// Pinned instances carry their depth in their own per-panel state instead,
784
+ /// which is what makes two ladders on one page able to differ.
785
+ OrderBookWidget.DEPTH_KEY = 'terminal.obDepth';
786
+ /// Persisted row layout for the follows-active instance.
787
+ OrderBookWidget.VIEW_KEY = 'terminal.obView';
788
+ /// Persisted "bids above the mid line" flag for the follows-active instance.
789
+ OrderBookWidget.INVERT_KEY = 'terminal.obInvert';
790
+ /// Persisted depth-chart visibility for the follows-active instance.
791
+ OrderBookWidget.DEPTHCHART_KEY = 'terminal.obDepthChart';
792
+ //# sourceMappingURL=orderbook-widget.js.map