@stocksharp/trading-controls 0.1.0 → 1.1.1

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 +22 -3
  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,744 @@
1
+ // Trade feed — multi-instance.
2
+ //
3
+ // The public tape: every print for the symbol the page is showing, plus any
4
+ // extra instruments this instance has pinned, in one of two renderings. The
5
+ // list is a row per print; the bubble chart is the same prints scattered by
6
+ // time and price, sized by volume and coloured by direction. Which of the two
7
+ // is showing is shared across every feed on the page through the host's
8
+ // preference store; the pinned extras belong to the instance and ride in its
9
+ // persisted state.
10
+ //
11
+ // Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
12
+ // the page, so it can be constructed by any host that supplies a `TradingHost`.
13
+ //
14
+ // Ticks are pushed in — `setTrades` for a fresh symbol, `addTrade` per print —
15
+ // because a host fans one socket to every live feed rather than each of them
16
+ // subscribing again. The one thing the panel pulls is the account's own fills,
17
+ // on the My-trades tab, through `trading.api.getExecutions`.
18
+ //
19
+ // The chart is a <canvas>, the single surface a class name cannot reach: its
20
+ // geometry comes out of `tradefeed-bubbles.ts` as numbers and its colours out
21
+ // of the host's `canvasPalette()`, so the package still paints nothing from a
22
+ // palette of its own.
23
+ import { formatPrice, formatQty, formatTime } from './formatters.js';
24
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
25
+ import { ControlTypes } from './control-types.js';
26
+ import { makeGridMenu } from './grid-menu.js';
27
+ import { MarketDataLevels, TradingHost, assertHost } from './trading-host.js';
28
+ import type { TradeRow } from './trading-data.js';
29
+ import type { FeedBubble, FeedTick } from './tradefeed-aggregator.js';
30
+ import { layoutBubbles, type BubbleLane, type BubbleShape } from './tradefeed-bubbles.js';
31
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
32
+
33
+ // A print carries no id of its own, so the widget stamps one on arrival — the
34
+ // grid addresses rows by key, and a tape with fifty anonymous rows would have
35
+ // no identities to diff, select or flash by.
36
+ type KeyedTrade = TradeRow & { _k?: number };
37
+
38
+ /// The panel needs nothing beyond the host port. It reports no application
39
+ /// action: a tape is read, and the two gestures it does make — pin an
40
+ /// instrument, open another feed — are the host's own `pickInstrument` and
41
+ /// `spawn`.
42
+ export interface TradeFeedDeps {
43
+ host: TradingHost;
44
+ }
45
+
46
+ // Opacity, not colour. The hue of every stroke below is the host's
47
+ // `canvasPalette`; how solid it is drawn is the chart's own business, and
48
+ // keeping the two apart is what lets one palette serve a light theme and a dark
49
+ // one without the package knowing which it is in.
50
+ const FILL_ALPHA = 0.55;
51
+ const STROKE_ALPHA = 0.9;
52
+ const LABEL_ALPHA = 0.55;
53
+ const SEPARATOR_ALPHA = 0.12;
54
+ const RULE_ALPHA = 0.08;
55
+
56
+ // How far from a bubble's edge a cursor still counts as over it. Small bubbles
57
+ // are otherwise impossible to hit.
58
+ const HOVER_SLACK = 6;
59
+ // Gap between the cursor and the tooltip, and between the tooltip and the edge
60
+ // it is clamped against.
61
+ const TOOLTIP_OFFSET = 12;
62
+ const TOOLTIP_MARGIN = 4;
63
+
64
+ export class TradeFeedWidget {
65
+ static TYPE = ControlTypes.TradeFeed;
66
+ /// Shared across every trade feed on the page: which of the two renderings
67
+ /// the user last chose. The value is what a user's stored settings already
68
+ /// hold, so it is spelled exactly as it always was.
69
+ static VIEW_KEY = 'terminal.tradeFeedView';
70
+ /// Rows kept in the list, and prints kept for the chart. The chart holds
71
+ /// far more because it compacts them: a bubble stands for a slice.
72
+ static MAX_ROWS = 50;
73
+ static MAX_BUBBLES = 500;
74
+
75
+ rootEl: HTMLElement;
76
+ extrasEl: HTMLElement | null;
77
+ bubbleCanvas: HTMLCanvasElement | null;
78
+ trades: TradeRow[];
79
+ bubbleTrades: TradeRow[];
80
+ myTrades: TradeRow[];
81
+ avgQty: number;
82
+ tab: string;
83
+ view: string;
84
+ // `//` rather than `///` from here down — see the note in positions-widget.
85
+ _host: TradingHost;
86
+ _marketGrid: DataGrid<TradeRow> | null;
87
+ _myGrid: DataGrid<TradeRow> | null;
88
+ // The stamp the next anonymous print gets — see KeyedTrade.
89
+ _seq: number;
90
+ _tabsEl: HTMLElement | null;
91
+ _viewToggleEl: HTMLElement | null;
92
+ _tooltipEl: HTMLElement | null;
93
+ _addBtn: HTMLElement | null;
94
+ _closeBtn: HTMLElement | null;
95
+ _addSymbolBtn: HTMLElement | null;
96
+ _bubbleCtx: CanvasRenderingContext2D | null;
97
+ // Rebuilt on every paint: where each bubble landed and what it stands for.
98
+ // Hover walks it backwards so the circle drawn last — the newest print —
99
+ // wins wherever two overlap.
100
+ _bubbleHits: BubbleShape[];
101
+ _canvasSize: { width: number; height: number } | null;
102
+ _resizeObserver: ResizeObserver | null;
103
+ _activeSymbol: string | null;
104
+ _extraSymbols: Set<string>;
105
+
106
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: TradeFeedDeps): TradeFeedWidget {
107
+ // Assert before building: the markup below is localized through the
108
+ // host, so a missing host has to fail here rather than render a panel
109
+ // captioned with raw English keys.
110
+ const host = assertHost(deps?.host, 'TradeFeedWidget');
111
+ const root = TradeFeedWidget._buildRoot(host);
112
+ root.id = makePanelId(TradeFeedWidget.TYPE);
113
+ hostEl.appendChild(root);
114
+ return new TradeFeedWidget(root, state || {}, deps);
115
+ }
116
+
117
+ // The panel's markup. The host stylesheet reads this structure, and a
118
+ // docking host lifts `.panel-header`'s children into its tab strip.
119
+ //
120
+ // Two `+` buttons, and they are not the same gesture: the one in the header
121
+ // pins another instrument to THIS feed, the one beside it asks the host for
122
+ // another feed panel. The tooltips are what tell them apart, so neither is
123
+ // an icon on its own.
124
+ static _buildRoot(host: TradingHost): HTMLElement {
125
+ const addInstrument = host.t('AddInstrument');
126
+ const marketTrades = host.t('MarketTrades');
127
+ return makePanelRoot('tradefeed-panel tf-tab-market tf-view-list', host.t('TradeFeed'), [
128
+ makeElement('div', 'panel-header', {}, [
129
+ makeElement('span', '', {}, [marketTrades]),
130
+ makeIconButton('bt-icon-btn tf-add-symbol-btn', addInstrument, 'bi-plus-lg', { type: 'button' }),
131
+ makeIconButton('bt-icon-btn panel-add-btn', host.t('Add trade feed'), 'bi-window-plus', { type: 'button' }),
132
+ makeElement('div', 'tradefeed-view-toggle tf-view-toggle', { role: 'group', 'aria-label': host.t('TradeFeedView') }, [
133
+ makeIconButton('tf-view-btn', host.t('ListView'), 'bi-list-ul', { type: 'button', 'data-view': 'list' }),
134
+ makeIconButton('tf-view-btn', host.t('BubbleChart'), 'bi-circle-fill', { type: 'button', 'data-view': 'bubbles' }),
135
+ ]),
136
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
137
+ ]),
138
+ makeElement('div', 'tf-tabs', { role: 'tablist', 'aria-label': host.t('Trade feed tabs') }, [
139
+ makeElement('button', 'tf-tab active', { type: 'button', role: 'tab', 'aria-selected': 'true', 'data-tab': 'market' }, [marketTrades]),
140
+ makeElement('button', 'tf-tab', { type: 'button', role: 'tab', 'aria-selected': 'false', 'data-tab': 'my' }, [host.t('My trades')]),
141
+ ]),
142
+ makeElement('div', 'tf-extras', { role: 'list', 'aria-label': host.t('ExtraInstruments'), hidden: '' }, []),
143
+ // Each tape is a DataGrid over the shared table skin: the <thead>
144
+ // is left empty for the grid to fill, which is also what makes the
145
+ // tape sortable and gives it the grid's menu and selection.
146
+ makeElement('div', 'tradefeed-content tf-market', { 'aria-live': 'polite' }, [
147
+ makeElement('table', 'terminal-table tradefeed-table', { role: 'table', 'aria-label': marketTrades }, [
148
+ makeElement('thead', '', {}, []),
149
+ makeElement('tbody', '', {}, []),
150
+ ]),
151
+ ]),
152
+ makeElement('div', 'tradefeed-content tf-my', {}, [
153
+ makeElement('table', 'terminal-table tradefeed-table', { role: 'table', 'aria-label': host.t('My trades') }, [
154
+ makeElement('thead', '', {}, []),
155
+ makeElement('tbody', '', {}, []),
156
+ ]),
157
+ ]),
158
+ makeElement('canvas', 'tradefeed-bubbles tf-bubbles', { 'aria-hidden': 'true' }, []),
159
+ makeElement('div', 'tf-bubble-tooltip', { role: 'tooltip', hidden: '' }, []),
160
+ ]);
161
+ }
162
+
163
+ constructor(rootEl: HTMLElement, state: Record<string, unknown>, deps: TradeFeedDeps) {
164
+ this._host = assertHost(deps?.host, 'TradeFeedWidget');
165
+
166
+ this.rootEl = rootEl;
167
+ this.extrasEl = this.rootEl.querySelector('.tf-extras');
168
+ this.bubbleCanvas = this.rootEl.querySelector('.tf-bubbles');
169
+ this._tabsEl = this.rootEl.querySelector('.tf-tabs');
170
+ this._viewToggleEl = this.rootEl.querySelector('.tf-view-toggle');
171
+ this._tooltipEl = this.rootEl.querySelector('.tf-bubble-tooltip');
172
+ this._addBtn = this.rootEl.querySelector('.panel-add-btn');
173
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
174
+ this._addSymbolBtn = this.rootEl.querySelector('.tf-add-symbol-btn');
175
+ this._bubbleCtx = this.bubbleCanvas?.getContext('2d') ?? null;
176
+
177
+ this.trades = [];
178
+ this.bubbleTrades = [];
179
+ this.myTrades = [];
180
+ // Seed rather than zero: the "unusually large" test is a multiple of the
181
+ // running average, and an average starting at zero would flag the first
182
+ // print of the session.
183
+ this.avgQty = 100;
184
+ this.tab = 'market';
185
+ this._seq = 0;
186
+ this._bubbleHits = [];
187
+ this._canvasSize = null;
188
+ this._resizeObserver = null;
189
+
190
+ this._marketGrid = this._makeGrid('.tf-market');
191
+ this._myGrid = this._makeGrid('.tf-my');
192
+
193
+ const saved = this._host.preferences.get(TradeFeedWidget.VIEW_KEY, null);
194
+ this.view = saved === 'bubbles' ? 'bubbles' : 'list';
195
+
196
+ // The page's active symbol drives the primary feed; extras are the
197
+ // instruments this instance pinned on top of it, each refcount-
198
+ // subscribed and shown with a symbol column.
199
+ this._activeSymbol = null;
200
+ this._extraSymbols = new Set(Array.isArray(state.extras) ? state.extras as string[] : []);
201
+
202
+ this._addBtn?.addEventListener('click', (e) => {
203
+ e.preventDefault();
204
+ this._host.spawn({ extras: [...this._extraSymbols] });
205
+ });
206
+ this._closeBtn?.addEventListener('click', (e) => {
207
+ e.preventDefault();
208
+ this.dispose();
209
+ this._host.close();
210
+ });
211
+ this._addSymbolBtn?.addEventListener('click', (e) => {
212
+ e.preventDefault();
213
+ e.stopPropagation();
214
+ this._host.trading.pickInstrument((symbol) => void this.addExtraSymbol(symbol));
215
+ });
216
+
217
+ this._bindTabs();
218
+ this._bindViewToggle();
219
+ this._bindBubbleHover();
220
+ this._applyView();
221
+ this._renderExtras();
222
+
223
+ // Resubscribe the persisted extras up front: without this a reload
224
+ // renders the chip and streams nothing behind it.
225
+ for (const symbol of this._extraSymbols)
226
+ void this._host.trading.marketData.addSymbol(symbol, MarketDataLevels.Tape).catch(() => { });
227
+
228
+ if (this.bubbleCanvas && typeof ResizeObserver === 'function') {
229
+ this._resizeObserver = new ResizeObserver(() => { this._sizeCanvas(); this._renderBubbles(); });
230
+ this._resizeObserver.observe(this.bubbleCanvas);
231
+ }
232
+
233
+ this._host.register(this);
234
+ }
235
+
236
+ dispose(): void {
237
+ try { this._resizeObserver?.disconnect(); } catch { /* already torn down */ }
238
+ // The grids hold document-level listeners (the copy shortcut) that
239
+ // outlive a removed subtree — they have to be told, not just detached.
240
+ this._marketGrid?.destroy();
241
+ this._myGrid?.destroy();
242
+ // Drop this feed's share of every extra so a symbol nobody else watches
243
+ // stops streaming — the client refcounts, so a neighbour keeps its own.
244
+ for (const symbol of this._extraSymbols)
245
+ void this._host.trading.marketData.removeSymbol(symbol).catch(() => { });
246
+ this._host.unregister(this);
247
+ try { this.rootEl.remove(); } catch { /* already detached */ }
248
+ }
249
+
250
+ /// Which symbol the rest of the page is showing. The feed accepts prints
251
+ /// for it without it being pinned, and re-syncs so the symbol column
252
+ /// appears or disappears with the pinned set.
253
+ setActiveSymbol(symbol: string | null): void {
254
+ this._activeSymbol = symbol || null;
255
+ this._renderExtras();
256
+ this._renderBubbles();
257
+ }
258
+
259
+ /// Replace the tape wholesale — what a host does when the page moves to a
260
+ /// different instrument. The flash baseline resets with it: a new
261
+ /// instrument's history is history, not fifty arrivals at once.
262
+ setTrades(trades: TradeRow[]): void {
263
+ const rows = (trades || []).map(t => this._stamp(t));
264
+ this.trades = rows.slice(0, TradeFeedWidget.MAX_ROWS);
265
+ this.bubbleTrades = rows.slice(0, TradeFeedWidget.MAX_BUBBLES);
266
+ if (this.trades.length > 0) {
267
+ const total = this.trades.reduce((sum, t) => sum + (t.quantity ?? 0), 0);
268
+ this.avgQty = total / this.trades.length || this.avgQty;
269
+ }
270
+ this._marketGrid?.flashReset();
271
+ this._marketGrid?.setRows([...this.trades]);
272
+ this._renderBubbles();
273
+ }
274
+
275
+ /// One print off the wire. Ignored unless this feed watches its symbol:
276
+ /// a host fans every tick to every feed and each decides for itself, which
277
+ /// is the only arrangement that supports per-instance extras.
278
+ addTrade(trade: TradeRow): void {
279
+ if (!trade || !this._watches(trade.symbol)) return;
280
+ this._stamp(trade);
281
+ this.trades.unshift(trade);
282
+ if (this.trades.length > TradeFeedWidget.MAX_ROWS) this.trades.length = TradeFeedWidget.MAX_ROWS;
283
+ this.bubbleTrades.unshift(trade);
284
+ if (this.bubbleTrades.length > TradeFeedWidget.MAX_BUBBLES) this.bubbleTrades.length = TradeFeedWidget.MAX_BUBBLES;
285
+ // A rolling average rather than a re-sum: "unusually large" should track
286
+ // the recent tape, not the whole session.
287
+ this.avgQty = this.avgQty * 0.95 + (trade.quantity ?? 0) * 0.05;
288
+
289
+ // The grid diffs by key, and its flashNewClass is what marks the
290
+ // newcomer — the hand-built prepend this replaces did both by hand.
291
+ this._marketGrid?.setRows([...this.trades]);
292
+ this._renderBubbles();
293
+ }
294
+
295
+ /// The account's own fills for the My-trades tab. The one thing this panel
296
+ /// pulls rather than being pushed.
297
+ async loadMyTrades(portfolioId: number | null, symbol: string | null): Promise<void> {
298
+ if (!portfolioId) {
299
+ this.myTrades = [];
300
+ this._renderMy();
301
+ return;
302
+ }
303
+ try {
304
+ this.myTrades = await this._host.trading.api.getExecutions(portfolioId, symbol, TradeFeedWidget.MAX_ROWS) || [];
305
+ } catch (err) {
306
+ // Through the port, not the console: this is the diagnostic the
307
+ // user cannot see and support has to, and routing it here is also
308
+ // what makes it assertable.
309
+ this._host.log(`TradeFeedWidget: failed to load own trades: ${err}`);
310
+ this.myTrades = [];
311
+ }
312
+ this._renderMy();
313
+ }
314
+
315
+ // ---------------------------------------------------------------- the grid
316
+
317
+ // One construction for both tapes: same columns, same chrome, different
318
+ // tbody. The container holds the scroll; the grid holds the rows.
319
+ _makeGrid(containerSelector: string): DataGrid<TradeRow> | null {
320
+ const head = this.rootEl.querySelector(`${containerSelector} thead`);
321
+ const body = this.rootEl.querySelector(`${containerSelector} tbody`);
322
+ if (!head || !body) return null;
323
+ return new DataGrid<TradeRow>({
324
+ head: head as HTMLElement,
325
+ body: body as HTMLElement,
326
+ columns: this._columns(),
327
+ // Newest print on top — the only order a tape is read in at rest;
328
+ // a header click can still reorder, and clears back to this.
329
+ defaultSort: { col: 'time', dir: 'desc' },
330
+ rowKey: (t) => TradeFeedWidget._key(t),
331
+ emptyText: this._host.t('No trades yet'),
332
+ rowClass: (t) => this._rowClass(t),
333
+ contextMenu: makeGridMenu(this._host),
334
+ selection: 'multi',
335
+ // The grid marks what is new since the previous paint; the
336
+ // stylesheet decides what a flash looks like.
337
+ flashNewClass: 'flash-new',
338
+ });
339
+ }
340
+
341
+ // Prints arrive anonymous; executions arrive with ids. Stamp the former
342
+ // once, on arrival, and key both without ever colliding.
343
+ _stamp(trade: TradeRow): TradeRow {
344
+ const keyed = trade as KeyedTrade;
345
+ if (keyed._k == null && keyed.id == null) keyed._k = ++this._seq;
346
+ return trade;
347
+ }
348
+
349
+ static _key(trade: TradeRow): string {
350
+ const keyed = trade as KeyedTrade;
351
+ return keyed.id != null ? String(keyed.id) : `p${keyed._k}`;
352
+ }
353
+
354
+ // The direction class sits on the row, so price and side inherit its
355
+ // colour while time and qty state their own — plus the "unusually large"
356
+ // mark, measured against the rolling average as of this paint.
357
+ _rowClass(trade: TradeRow): string {
358
+ const classes = this._host.presentation.sideClass(trade.side!).split(' ').filter(Boolean);
359
+ if ((trade.quantity ?? 0) > this.avgQty * 2) classes.push('large-trade');
360
+ return classes.join(' ');
361
+ }
362
+
363
+ // The tape's single column declaration, shared by both tabs. `time` sorts
364
+ // by the wire timestamp and shows the clock; the symbol column exists
365
+ // always and is hidden until a second instrument is in the feed.
366
+ _columns(): GridColumn<TradeRow>[] {
367
+ const label = (key: string) => this._host.t(key);
368
+ const presentation = this._host.presentation;
369
+ return [
370
+ {
371
+ key: 'time',
372
+ header: label('Time'),
373
+ exportable: true,
374
+ value: (t) => String(t.time || t.executedAt || ''),
375
+ render: (t) => formatTime(t.time || t.executedAt),
376
+ },
377
+ {
378
+ key: 'symbol',
379
+ header: label('Sym'),
380
+ exportable: true,
381
+ value: (t) => t.symbol,
382
+ },
383
+ {
384
+ key: 'price',
385
+ header: label('Price'),
386
+ exportable: true,
387
+ value: (t) => t.price,
388
+ render: (t) => formatPrice(t.price),
389
+ },
390
+ {
391
+ key: 'quantity',
392
+ header: label('Qty'),
393
+ exportable: true,
394
+ value: (t) => t.quantity,
395
+ render: (t) => formatQty(t.quantity),
396
+ },
397
+ {
398
+ key: 'side',
399
+ header: label('Side'),
400
+ exportable: true,
401
+ value: (t) => t.side,
402
+ render: (t) => presentation.sideText(t.side!),
403
+ exportValue: (t) => presentation.sideText(t.side!),
404
+ },
405
+ ];
406
+ }
407
+
408
+ // Both tapes show the symbol column exactly while a second instrument is
409
+ // in the feed — through the grid's own column visibility, so the user's
410
+ // hide/show from the menu still works the rest of the time.
411
+ _syncSymbolColumn(): void {
412
+ const multi = this._extraSymbols.size > 0;
413
+ for (const grid of [this._marketGrid, this._myGrid]) {
414
+ if (!grid) continue;
415
+ if (multi) grid.showColumn('symbol');
416
+ else grid.hideColumn('symbol');
417
+ }
418
+ }
419
+
420
+ /// Pin an extra instrument to this feed: subscribe it, show it alongside
421
+ /// the primary, and remember it in the instance's state.
422
+ async addExtraSymbol(symbol: string): Promise<void> {
423
+ if (!symbol) return;
424
+ const sym = String(symbol).toUpperCase();
425
+ if (sym === this._activeSymbol || this._extraSymbols.has(sym)) return;
426
+ this._extraSymbols.add(sym);
427
+ // A tape needs the prints, and only the prints — the depth `Full` would
428
+ // add is bandwidth this panel never renders.
429
+ try { await this._host.trading.marketData.addSymbol(sym, MarketDataLevels.Tape); } catch { /* socket race */ }
430
+ this._renderExtras();
431
+ this._renderBubbles();
432
+ this._persistExtras();
433
+ }
434
+
435
+ /// Unpin one, dropping its subscription and the prints already on screen.
436
+ async removeExtraSymbol(symbol: string): Promise<void> {
437
+ if (!symbol) return;
438
+ const sym = String(symbol).toUpperCase();
439
+ if (!this._extraSymbols.has(sym)) return;
440
+ this._extraSymbols.delete(sym);
441
+ try { await this._host.trading.marketData.removeSymbol(sym); } catch { /* socket race */ }
442
+ this.trades = this.trades.filter(t => t.symbol !== sym);
443
+ this.bubbleTrades = this.bubbleTrades.filter(t => t.symbol !== sym);
444
+ this._marketGrid?.setRows([...this.trades]);
445
+ this._renderExtras();
446
+ this._renderBubbles();
447
+ this._persistExtras();
448
+ }
449
+
450
+ getExtraSymbols(): string[] {
451
+ return [...this._extraSymbols];
452
+ }
453
+
454
+ // Record what this instance pins, then ask for the layout to be flushed.
455
+ // Two calls because they are two decisions: what the instance remembers,
456
+ // and whether that is worth writing out now. Pinning is — losing the pin on
457
+ // the next reload is exactly the complaint.
458
+ _persistExtras(): void {
459
+ this._host.persistState({ extras: [...this._extraSymbols] });
460
+ this._host.saveLayout();
461
+ }
462
+
463
+ // Does this feed show `symbol` — as the page's active one, or pinned?
464
+ _watches(symbol: string | undefined): boolean {
465
+ if (!symbol) return false;
466
+ return symbol === this._activeSymbol || this._extraSymbols.has(symbol);
467
+ }
468
+
469
+ // ---------------------------------------------------------------- the list
470
+
471
+ _renderMy(): void {
472
+ this._myGrid?.setRows(this.myTrades.map(t => this._stamp(t)));
473
+ }
474
+
475
+ _renderExtras(): void {
476
+ // Every path that changes the pinned set comes through here, so the
477
+ // symbol column tracks the set without a second call at each site.
478
+ this._syncSymbolColumn();
479
+ if (!this.extrasEl) return;
480
+ if (this._extraSymbols.size === 0) {
481
+ this.extrasEl.replaceChildren();
482
+ this.extrasEl.setAttribute('hidden', '');
483
+ return;
484
+ }
485
+ this.extrasEl.removeAttribute('hidden');
486
+ const remove = this._host.t('Remove');
487
+ const chips = [...this._extraSymbols].map((symbol) => {
488
+ const button = makeIconButton('tf-extra-rm', remove, 'bi-x', { type: 'button' });
489
+ button.addEventListener('click', (e) => {
490
+ e.preventDefault();
491
+ e.stopPropagation();
492
+ void this.removeExtraSymbol(symbol);
493
+ });
494
+ return makeElement('span', 'tf-extra-chip', { role: 'listitem' }, [
495
+ makeElement('span', 'tf-extra-sym', {}, [symbol]),
496
+ button,
497
+ ]);
498
+ });
499
+ this.extrasEl.replaceChildren(...chips);
500
+ }
501
+
502
+ // --------------------------------------------------------------- the views
503
+
504
+ _bindTabs(): void {
505
+ this._tabsEl?.addEventListener('click', (e) => {
506
+ const button = (e.target as Element | null)?.closest('.tf-tab') as HTMLElement | null;
507
+ if (!button) return;
508
+ this._selectTab(button.dataset.tab || 'market');
509
+ });
510
+ }
511
+
512
+ _selectTab(tab: string): void {
513
+ this.tab = tab === 'my' ? 'my' : 'market';
514
+ this._tabsEl?.querySelectorAll('.tf-tab').forEach((b) => {
515
+ const selected = (b as HTMLElement).dataset.tab === this.tab;
516
+ b.classList.toggle('active', selected);
517
+ b.setAttribute('aria-selected', selected ? 'true' : 'false');
518
+ });
519
+ this.rootEl.classList.toggle('tf-tab-market', this.tab === 'market');
520
+ this.rootEl.classList.toggle('tf-tab-my', this.tab === 'my');
521
+ // The tab is also the request: opening it is when the fills are worth
522
+ // fetching, and the portfolio is read per use because the user can have
523
+ // switched account under a panel that is already on screen.
524
+ if (this.tab === 'my') void this.loadMyTrades(this._host.trading.portfolioId(), this._activeSymbol);
525
+ else this._renderBubbles();
526
+ }
527
+
528
+ _bindViewToggle(): void {
529
+ this._viewToggleEl?.addEventListener('click', (e) => {
530
+ const button = (e.target as Element | null)?.closest('.tf-view-btn') as HTMLElement | null;
531
+ if (!button) return;
532
+ const next = button.dataset.view;
533
+ if ((next !== 'list' && next !== 'bubbles') || next === this.view) return;
534
+ this.view = next;
535
+ this._host.preferences.set(TradeFeedWidget.VIEW_KEY, next);
536
+ this._applyView();
537
+ });
538
+ }
539
+
540
+ // Which rendering is showing is two class names on the root, not a display
541
+ // written onto elements: a narrow viewport forces the list back on, and
542
+ // that is a stylesheet's decision to make, not this control's.
543
+ _applyView(): void {
544
+ const list = this.view === 'list';
545
+ this.rootEl.classList.toggle('tf-view-list', list);
546
+ this.rootEl.classList.toggle('tf-view-bubbles', !list);
547
+ this._viewToggleEl?.querySelectorAll('.tf-view-btn').forEach((b) => {
548
+ const selected = (b as HTMLElement).dataset.view === this.view;
549
+ b.classList.toggle('active', selected);
550
+ b.setAttribute('aria-pressed', selected ? 'true' : 'false');
551
+ });
552
+ if (!list) {
553
+ this._sizeCanvas();
554
+ this._renderBubbles();
555
+ }
556
+ }
557
+
558
+ // -------------------------------------------------------------- the chart
559
+
560
+ // The canvas in CSS pixels, and its backing store in device pixels. Without
561
+ // the second the chart is drawn at a third of the resolution the screen has.
562
+ _sizeCanvas(): void {
563
+ const canvas = this.bubbleCanvas;
564
+ if (!canvas) return;
565
+ const rect = canvas.getBoundingClientRect();
566
+ const ratio = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
567
+ const width = Math.max(1, Math.floor(rect.width));
568
+ const height = Math.max(1, Math.floor(rect.height));
569
+ if (canvas.width !== width * ratio || canvas.height !== height * ratio) {
570
+ canvas.width = width * ratio;
571
+ canvas.height = height * ratio;
572
+ this._bubbleCtx?.setTransform(ratio, 0, 0, ratio, 0, 0);
573
+ }
574
+ this._canvasSize = { width, height };
575
+ }
576
+
577
+ // The prints as the chart reads them: direction decided once by the host,
578
+ // and a position on the shared time axis. Newest first, as the tape holds
579
+ // them.
580
+ _feedTicks(): FeedTick[] {
581
+ const presentation = this._host.presentation;
582
+ return this.bubbleTrades.map((trade, index) => ({
583
+ symbol: trade.symbol || '',
584
+ side: trade.side!,
585
+ buy: presentation.isBuy(trade.side!),
586
+ price: Number(trade.price ?? 0),
587
+ quantity: Number(trade.quantity ?? 0),
588
+ time: trade.time || trade.executedAt || '',
589
+ index,
590
+ }));
591
+ }
592
+
593
+ // One lane per watched symbol, active first. A feed with nothing pinned is
594
+ // one lane over everything it holds, which is also the only case where the
595
+ // lane needs no name.
596
+ _lanes(ticks: FeedTick[]): BubbleLane[] {
597
+ if (this._extraSymbols.size === 0)
598
+ return [{ symbol: this._activeSymbol || '', ticks }];
599
+ const symbols = this._activeSymbol ? [this._activeSymbol] : [];
600
+ for (const symbol of this._extraSymbols) if (!symbols.includes(symbol)) symbols.push(symbol);
601
+ return symbols.map(symbol => ({ symbol, ticks: ticks.filter(t => t.symbol === symbol) }));
602
+ }
603
+
604
+ _renderBubbles(): void {
605
+ const ctx = this._bubbleCtx;
606
+ if (!ctx || this.view !== 'bubbles') return;
607
+ if (!this._canvasSize) this._sizeCanvas();
608
+ const size = this._canvasSize;
609
+ if (!size) return;
610
+
611
+ ctx.clearRect(0, 0, size.width, size.height);
612
+ this._bubbleHits = [];
613
+ const ticks = this._feedTicks();
614
+ if (ticks.length === 0) return;
615
+
616
+ const layout = layoutBubbles({ width: size.width, height: size.height, total: ticks.length, lanes: this._lanes(ticks) });
617
+ const palette = this._host.presentation.canvasPalette();
618
+ ctx.font = palette.font;
619
+ ctx.lineWidth = 1;
620
+
621
+ for (const lane of layout.lanes) {
622
+ if (lane.separatorY !== null) {
623
+ ctx.globalAlpha = SEPARATOR_ALPHA;
624
+ ctx.strokeStyle = palette.grid;
625
+ TradeFeedWidget._rule(ctx, 0, size.width, lane.separatorY);
626
+ }
627
+ if (lane.label) {
628
+ ctx.globalAlpha = LABEL_ALPHA;
629
+ ctx.fillStyle = palette.grid;
630
+ ctx.textBaseline = 'top';
631
+ ctx.textAlign = 'left';
632
+ ctx.fillText(lane.symbol, lane.label.x, lane.label.y);
633
+ }
634
+ for (const tick of lane.ticks) {
635
+ ctx.globalAlpha = RULE_ALPHA;
636
+ ctx.strokeStyle = palette.grid;
637
+ TradeFeedWidget._rule(ctx, 0, layout.axisX - 2, tick.y);
638
+ ctx.globalAlpha = LABEL_ALPHA;
639
+ ctx.fillStyle = palette.grid;
640
+ ctx.textBaseline = 'middle';
641
+ ctx.textAlign = 'left';
642
+ ctx.fillText(tick.text, layout.labelX, tick.y);
643
+ }
644
+ }
645
+
646
+ for (const shape of layout.bubbles) {
647
+ const colour = shape.bubble.buy ? palette.up : palette.down;
648
+ ctx.fillStyle = colour;
649
+ ctx.strokeStyle = colour;
650
+ ctx.beginPath();
651
+ ctx.arc(shape.x, shape.y, shape.radius, 0, Math.PI * 2);
652
+ // Filled softly and outlined firmly, so overlapping prints stay
653
+ // countable instead of merging into one blob.
654
+ ctx.globalAlpha = FILL_ALPHA;
655
+ ctx.fill();
656
+ ctx.globalAlpha = STROKE_ALPHA;
657
+ ctx.stroke();
658
+ this._bubbleHits.push(shape);
659
+ }
660
+ ctx.globalAlpha = 1;
661
+ }
662
+
663
+ static _rule(ctx: CanvasRenderingContext2D, from: number, to: number, y: number): void {
664
+ ctx.beginPath();
665
+ ctx.moveTo(from, y);
666
+ ctx.lineTo(to, y);
667
+ ctx.stroke();
668
+ }
669
+
670
+ _bindBubbleHover(): void {
671
+ const canvas = this.bubbleCanvas;
672
+ if (!canvas) return;
673
+ canvas.addEventListener('mousemove', (e: MouseEvent) => {
674
+ if (this.view !== 'bubbles') { this._hideTooltip(); return; }
675
+ const rect = canvas.getBoundingClientRect();
676
+ const x = e.clientX - rect.left;
677
+ const y = e.clientY - rect.top;
678
+ const hit = this._hitTest(x, y);
679
+ if (hit) this._showTooltip(hit.bubble, x, y);
680
+ else this._hideTooltip();
681
+ });
682
+ canvas.addEventListener('mouseleave', () => this._hideTooltip());
683
+ }
684
+
685
+ // Backwards, so the circle drawn last — the newest print, on top — is the
686
+ // one a cursor over both picks.
687
+ _hitTest(x: number, y: number): BubbleShape | null {
688
+ for (let i = this._bubbleHits.length - 1; i >= 0; i--) {
689
+ const shape = this._bubbleHits[i];
690
+ const dx = x - shape.x;
691
+ const dy = y - shape.y;
692
+ const reach = Math.max(shape.radius, HOVER_SLACK);
693
+ if (dx * dx + dy * dy <= reach * reach) return shape;
694
+ }
695
+ return null;
696
+ }
697
+
698
+ _showTooltip(bubble: FeedBubble, x: number, y: number): void {
699
+ const tooltip = this._tooltipEl;
700
+ const canvas = this.bubbleCanvas;
701
+ if (!tooltip || !canvas) return;
702
+ const presentation = this._host.presentation;
703
+ const aggregated = bubble.count > 1;
704
+ // The raw side the print carried, not a spelling this file picked:
705
+ // wording and colouring it are the host's, whichever of the three
706
+ // spellings its wire uses.
707
+ const side = bubble.side;
708
+
709
+ const lines: HTMLElement[] = [];
710
+ if (this._extraSymbols.size > 0) lines.push(this._tooltipLine(this._host.t('Symbol'), bubble.symbol, ''));
711
+ lines.push(this._tooltipLine(this._host.t('Time'), formatTime(bubble.time), ''));
712
+ lines.push(this._tooltipLine(aggregated ? this._host.t('VWAP') : this._host.t('Price'), formatPrice(bubble.price), ''));
713
+ lines.push(this._tooltipLine(aggregated ? this._host.t('Total qty') : this._host.t('Qty'), formatQty(bubble.quantity), ''));
714
+ lines.push(this._tooltipLine(this._host.t('Side'), presentation.sideText(side), presentation.sideClass(side)));
715
+ if (aggregated) lines.push(this._tooltipLine(this._host.t('Trades'), String(bubble.count), ''));
716
+ tooltip.replaceChildren(...lines);
717
+
718
+ // Measured, then placed: the size is only known once it is showing, and
719
+ // it is clamped inside the canvas so a print near the right or bottom
720
+ // edge does not push its own tooltip out of view.
721
+ tooltip.removeAttribute('hidden');
722
+ const canvasRect = canvas.getBoundingClientRect();
723
+ const parentRect = tooltip.offsetParent?.getBoundingClientRect() ?? canvasRect;
724
+ const originX = canvasRect.left - parentRect.left;
725
+ const originY = canvasRect.top - parentRect.top;
726
+ const maxX = originX + canvasRect.width - tooltip.offsetWidth - TOOLTIP_MARGIN;
727
+ const maxY = originY + canvasRect.height - tooltip.offsetHeight - TOOLTIP_MARGIN;
728
+ tooltip.style.left = `${Math.max(0, Math.min(originX + x + TOOLTIP_OFFSET, maxX))}px`;
729
+ tooltip.style.top = `${Math.max(0, Math.min(originY + y + TOOLTIP_OFFSET, maxY))}px`;
730
+ }
731
+
732
+ _tooltipLine(label: string, value: string, valueClass: string): HTMLElement {
733
+ const row = makeElement('div', 'tf-bbtt-row', {}, [
734
+ makeElement('span', 'tf-bbtt-label', {}, [label]),
735
+ makeElement('span', 'tf-bbtt-value', {}, [value]),
736
+ ]);
737
+ for (const name of valueClass.split(' ')) if (name) (row.childNodes[1] as HTMLElement).classList.add(name);
738
+ return row;
739
+ }
740
+
741
+ _hideTooltip(): void {
742
+ this._tooltipEl?.setAttribute('hidden', '');
743
+ }
744
+ }