@stocksharp/trading-controls 0.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 (59) hide show
  1. package/LICENSE +30 -0
  2. package/NOTICE +21 -0
  3. package/README.md +240 -0
  4. package/dist/esm/active-orders-widget.js +382 -0
  5. package/dist/esm/active-orders-widget.js.map +1 -0
  6. package/dist/esm/control-types.js +23 -0
  7. package/dist/esm/control-types.js.map +1 -0
  8. package/dist/esm/dom.js +55 -0
  9. package/dist/esm/dom.js.map +1 -0
  10. package/dist/esm/formatters.js +69 -0
  11. package/dist/esm/formatters.js.map +1 -0
  12. package/dist/esm/index.js +12 -0
  13. package/dist/esm/index.js.map +1 -0
  14. package/dist/esm/positions-widget.js +258 -0
  15. package/dist/esm/positions-widget.js.map +1 -0
  16. package/dist/esm/trade-history-widget.js +197 -0
  17. package/dist/esm/trade-history-widget.js.map +1 -0
  18. package/dist/esm/trading-data.js +26 -0
  19. package/dist/esm/trading-data.js.map +1 -0
  20. package/dist/esm/trading-host.js +79 -0
  21. package/dist/esm/trading-host.js.map +1 -0
  22. package/dist/esm/watchlist-widget.js +481 -0
  23. package/dist/esm/watchlist-widget.js.map +1 -0
  24. package/dist/sstradingcontrols.js +1740 -0
  25. package/dist/sstradingcontrols.js.map +7 -0
  26. package/dist/types/active-orders-widget.d.ts +52 -0
  27. package/dist/types/active-orders-widget.d.ts.map +1 -0
  28. package/dist/types/control-types.d.ts +8 -0
  29. package/dist/types/control-types.d.ts.map +1 -0
  30. package/dist/types/dom.d.ts +6 -0
  31. package/dist/types/dom.d.ts.map +1 -0
  32. package/dist/types/formatters.d.ts +8 -0
  33. package/dist/types/formatters.d.ts.map +1 -0
  34. package/dist/types/index.d.ts +16 -0
  35. package/dist/types/index.d.ts.map +1 -0
  36. package/dist/types/positions-widget.d.ts +37 -0
  37. package/dist/types/positions-widget.d.ts.map +1 -0
  38. package/dist/types/trade-history-widget.d.ts +25 -0
  39. package/dist/types/trade-history-widget.d.ts.map +1 -0
  40. package/dist/types/trading-data.d.ts +55 -0
  41. package/dist/types/trading-data.d.ts.map +1 -0
  42. package/dist/types/trading-host.d.ts +62 -0
  43. package/dist/types/trading-host.d.ts.map +1 -0
  44. package/dist/types/watchlist-widget.d.ts +60 -0
  45. package/dist/types/watchlist-widget.d.ts.map +1 -0
  46. package/package.json +129 -0
  47. package/src/active-orders-widget.ts +411 -0
  48. package/src/control-types.ts +24 -0
  49. package/src/dom.ts +68 -0
  50. package/src/formatters.ts +72 -0
  51. package/src/index.ts +52 -0
  52. package/src/positions-widget.ts +305 -0
  53. package/src/trade-history-widget.ts +221 -0
  54. package/src/trading-data.ts +121 -0
  55. package/src/trading-host.ts +300 -0
  56. package/src/watchlist-widget.ts +513 -0
  57. package/styles/theme.css +86 -0
  58. package/styles/trading-controls.css +469 -0
  59. package/translation-keys.json +58 -0
@@ -0,0 +1,305 @@
1
+ // Positions — multi-instance.
2
+ //
3
+ // Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
4
+ // the page it happens to be rendered on, so it can be constructed by any host
5
+ // that supplies a `TradingHost`. Every instance shares the same data source
6
+ // (the host's refresh / a position update off the socket); the host broadcasts
7
+ // each update to every live instance so duplicates of the panel render in
8
+ // lockstep.
9
+ //
10
+ // The table is `DataGrid` from `@stocksharp/grids`: the columns below are this
11
+ // blotter's single declaration and the <thead> is left empty for the grid to
12
+ // fill. The cash balance is a pinned row — a different shape from a position,
13
+ // so it supplies its own cells, sits outside the sort and stays out of the
14
+ // exported sheet.
15
+ import { formatPnl, formatPrice } from './formatters.js';
16
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
17
+ import { ControlTypes } from './control-types.js';
18
+ import { TradingHost, assertHost } from './trading-host.js';
19
+ import type { BalanceRow, PositionRow } from './trading-data.js';
20
+ import { DataGrid, GridColumn, GridPinnedRow, GridPinnedPlacements } from '@stocksharp/grids/source/data-grid';
21
+
22
+ /// Everything the blotter needs beyond the host port. All required: the action
23
+ /// buttons live in the cells and in the rail now, so a panel that cannot
24
+ /// close, reverse or reload a position is not this panel.
25
+ export interface PositionsDeps {
26
+ host: TradingHost;
27
+ closePosition(portfolioId: number, instrumentId: number, symbol: string): void;
28
+ reversePosition(portfolioId: number, instrumentId: number, symbol: string): void;
29
+ refreshPositions(): void;
30
+ }
31
+
32
+ export class PositionsWidget {
33
+ static TYPE = ControlTypes.Positions;
34
+
35
+ rootEl: HTMLElement;
36
+ el: HTMLElement | null;
37
+ // Comment style is `//` on purpose below the public members: the declaration
38
+ // emitter keeps a private member's doc comment while dropping its body, so a
39
+ // `///` here would reattach to the next public member in the API snapshot.
40
+ _host: TradingHost;
41
+ _deps: PositionsDeps;
42
+ _closeBtn: HTMLElement | null;
43
+ _refreshBtn: HTMLElement | null;
44
+ _exportBtn: HTMLElement | null;
45
+ _positions: PositionRow[];
46
+ _balance: BalanceRow | null;
47
+ _grid: DataGrid<PositionRow> | null;
48
+
49
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: PositionsDeps): PositionsWidget {
50
+ // Assert before building: the markup below is localized through the
51
+ // host, so a missing host has to fail here rather than render a panel
52
+ // captioned with raw English keys.
53
+ const host = assertHost(deps?.host, 'PositionsWidget');
54
+ const root = PositionsWidget._buildRoot(host);
55
+ root.id = makePanelId(PositionsWidget.TYPE);
56
+ hostEl.appendChild(root);
57
+ return new PositionsWidget(root, state || {}, deps);
58
+ }
59
+
60
+ // The panel's markup. The host stylesheet reads this structure, and a
61
+ // docking host lifts `.panel-header`'s children into its tab strip.
62
+ //
63
+ // The rail's refresh button is the one thing that could not come across
64
+ // unchanged from the terminal's Razor template: it used to be an inline
65
+ // `onclick="terminalApp.refreshPositions()"`, which is a reference to a
66
+ // global this control must not have. It carries `.panel-refresh-btn` now —
67
+ // the name the trade-history rail already gave the identical button — and
68
+ // is wired to the `refreshPositions` dep.
69
+ static _buildRoot(host: TradingHost): HTMLElement {
70
+ const title = host.t('OpenPositions');
71
+ return makePanelRoot('positions-panel', title, [
72
+ makeElement('div', 'panel-header', {}, [
73
+ makeElement('span', '', {}, [host.t('Positions')]),
74
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
75
+ ]),
76
+ makeElement('div', 'panel-body panel-body-with-rail', {}, [
77
+ makeElement('div', 'panel-body-content', {}, [
78
+ makeElement('table', 'terminal-table positions-table', { role: 'table', 'aria-label': title }, [
79
+ makeElement('thead', '', {}, []),
80
+ makeElement('tbody', 'positions-body', {}, []),
81
+ ]),
82
+ ]),
83
+ makeElement('div', 'panel-rail', { role: 'toolbar', 'aria-label': host.t('PositionsActions') }, [
84
+ makeIconButton('bt-icon-btn panel-refresh-btn', host.t('Refresh'), 'bi-arrow-clockwise', {}),
85
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
86
+ ]),
87
+ ]),
88
+ ]);
89
+ }
90
+
91
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: PositionsDeps) {
92
+ this._host = assertHost(deps?.host, 'PositionsWidget');
93
+ for (const name of ['closePosition', 'reversePosition', 'refreshPositions'] as const) {
94
+ if (typeof deps?.[name] !== 'function')
95
+ throw new Error(`PositionsWidget: dep "${name}" is required`);
96
+ }
97
+
98
+ this.rootEl = rootEl;
99
+ this._deps = deps;
100
+ this.el = this.rootEl.querySelector('.positions-body');
101
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
102
+ this._refreshBtn = this.rootEl.querySelector('.panel-refresh-btn');
103
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
104
+ this._positions = [];
105
+ this._balance = null;
106
+
107
+ this._closeBtn?.addEventListener('click', (e) => {
108
+ e.preventDefault();
109
+ this._host.close();
110
+ });
111
+
112
+ this._refreshBtn?.addEventListener('click', (e) => {
113
+ e.preventDefault();
114
+ this._deps.refreshPositions();
115
+ });
116
+
117
+ this._exportBtn?.addEventListener('click', (e) => {
118
+ e.preventDefault();
119
+ this._export();
120
+ });
121
+
122
+ const head = this.rootEl.querySelector('.positions-table thead');
123
+ this._grid = head && this.el
124
+ ? new DataGrid<PositionRow>({
125
+ head: head as HTMLElement,
126
+ body: this.el,
127
+ columns: this._columns(),
128
+ // Alphabetical by instrument at rest — a stable, predictable order (positions have no
129
+ // natural "newest") the user can scan by symbol.
130
+ defaultSort: { col: 'instrument', dir: 'asc' },
131
+ // Same identity applyDelta matches on: id alone isn't unique (id=0 for
132
+ // unsaved snapshots), and a portfolio can hold one position per instrument.
133
+ rowKey: (p) => PositionsWidget._key(p),
134
+ emptyText: this._host.t('No positions'),
135
+ // Re-read on every render, so the cash figures track the live balance
136
+ // without the widget having to repaint the position rows itself.
137
+ pinnedRows: () => this._pinnedRows(),
138
+ })
139
+ : null;
140
+
141
+ this._host.register(this);
142
+
143
+ // If the host already has a positions snapshot it will broadcast it on
144
+ // demand; new instances start empty until the first refresh tick.
145
+ }
146
+
147
+ dispose(): void {
148
+ this._host.unregister(this);
149
+ try { this.rootEl.remove(); } catch { /* already detached */ }
150
+ }
151
+
152
+ update(positions: PositionRow[]): void {
153
+ this._positions = positions || [];
154
+ this._grid?.setRows(this._positions);
155
+ }
156
+
157
+ /// Null clears the pinned row — which is not the same as an all-zero
158
+ /// balance, and reads differently: no row at all versus a row of zeros.
159
+ updateBalance(balance: BalanceRow | null): void {
160
+ this._balance = balance;
161
+ this._grid?.render();
162
+ }
163
+
164
+ /// Apply a single position delta. Match by (portfolioId, instrumentId) — id
165
+ /// alone isn't unique (id=0 for unsaved snapshots). Quantity hitting zero
166
+ /// closes the row.
167
+ applyDelta(position: PositionRow): void {
168
+ if (!position) return;
169
+ const idx = this._positions.findIndex(p =>
170
+ p.portfolioId === position.portfolioId &&
171
+ ((position.instrumentId != null && p.instrumentId === position.instrumentId) ||
172
+ (position.instrumentId == null && p.instrument === position.instrument)));
173
+ const closed = (position.quantity || 0) === 0;
174
+
175
+ if (idx >= 0) {
176
+ if (closed) this._positions.splice(idx, 1);
177
+ else this._positions[idx] = { ...this._positions[idx], ...position };
178
+ } else if (!closed) {
179
+ this._positions.push(position);
180
+ }
181
+ this.update(this._positions);
182
+ }
183
+
184
+ // Export the table to .xlsx in the currently rendered (sorted) order. The
185
+ // balance summary is a pinned row — a different shape from a position — so
186
+ // the grid keeps it out of the sheet.
187
+ _export(): void {
188
+ this._grid?.download('positions', this._host.t('Positions'));
189
+ }
190
+
191
+ // The blotter's single column declaration: what the header says, what the
192
+ // grid sorts on, what the cell shows, how it is coloured, and what reaches
193
+ // the exported sheet. Prices are formatted on screen but exported raw, so
194
+ // the sheet stays numeric.
195
+ _columns(): GridColumn<PositionRow>[] {
196
+ const label = (key: string) => this._host.t(key);
197
+ return [
198
+ {
199
+ key: 'instrument',
200
+ header: label('Symbol'),
201
+ exportable: true,
202
+ value: (p) => p.instrument,
203
+ },
204
+ {
205
+ key: 'quantity',
206
+ header: label('Qty'),
207
+ exportable: true,
208
+ value: (p) => p.quantity,
209
+ cellClass: (p) => ((p.quantity ?? 0) > 0 ? 'side-buy' : 'side-sell'),
210
+ },
211
+ {
212
+ key: 'avgPrice',
213
+ header: label('AvgPrice'),
214
+ exportable: true,
215
+ value: (p) => p.avgPrice,
216
+ render: (p) => formatPrice(p.avgPrice),
217
+ },
218
+ {
219
+ key: 'currentPrice',
220
+ header: label('Current'),
221
+ exportable: true,
222
+ value: (p) => p.currentPrice,
223
+ render: (p) => formatPrice(p.currentPrice),
224
+ },
225
+ {
226
+ key: 'pnl',
227
+ header: label('PnL'),
228
+ exportable: true,
229
+ value: (p) => PositionsWidget._totalPnl(p),
230
+ render: (p) => formatPnl(PositionsWidget._totalPnl(p)),
231
+ cellClass: (p) => this._host.presentation.pnlClass(PositionsWidget._totalPnl(p)),
232
+ },
233
+ {
234
+ key: 'actions',
235
+ header: label('Actions'),
236
+ headerHidden: true,
237
+ exportable: false,
238
+ cellClass: () => 'position-actions',
239
+ render: (p) => this._actionButtons(p),
240
+ },
241
+ ];
242
+ }
243
+
244
+ // The cash balance, pinned above the positions. It does not share the shape
245
+ // the position columns read, so it supplies its own cells; it is not a row
246
+ // of data, so it is neither sorted nor exported. Being content, it also
247
+ // suppresses the "no positions" row — a table showing a balance is not empty.
248
+ _pinnedRows(): GridPinnedRow[] {
249
+ const balance = this._balance;
250
+ if (!balance) return [];
251
+
252
+ const fmt = (v: number | null | undefined) => (v || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
253
+ return [{
254
+ key: 'balance',
255
+ className: 'balance-row',
256
+ place: GridPinnedPlacements.Top,
257
+ cells: [
258
+ { content: this._host.t('USD'), className: 'balance-ccy' },
259
+ { content: '$' + fmt(balance.available), className: 'balance-available' },
260
+ { content: this._host.t('Locked: ${0}', fmt(balance.locked)), className: 'balance-locked' },
261
+ { content: '', className: '' },
262
+ { content: '$' + fmt(balance.total), className: 'balance-total' },
263
+ ],
264
+ }];
265
+ }
266
+
267
+ // Realized and unrealized together — what the PnL column shows, sorts and
268
+ // exports, so the three cannot drift apart.
269
+ static _totalPnl(position: PositionRow): number {
270
+ return (position.unrealizedPnl || 0) + (position.realizedPnl || 0);
271
+ }
272
+
273
+ static _key(position: PositionRow): string {
274
+ return `${position.portfolioId}:${position.instrumentId ?? position.instrument}`;
275
+ }
276
+
277
+ // Close and reverse, wired to the controller through deps. Real buttons with
278
+ // their own listeners — the symbol used to be interpolated into an inline
279
+ // onclick attribute, which is why it needed quote-escaping first.
280
+ _actionButtons(position: PositionRow): Node {
281
+ const cell = document.createDocumentFragment();
282
+ cell.appendChild(this._actionButton(
283
+ 'bt-icon-cancel', 'bi-x-circle',
284
+ this._host.t('Close position'), this._host.t('Close position on {0}', position.instrument),
285
+ () => this._deps.closePosition(position.portfolioId!, position.instrumentId!, position.instrument!)));
286
+ cell.appendChild(this._actionButton(
287
+ 'bt-icon-reverse', 'bi-arrow-left-right',
288
+ this._host.t('Reverse position'), this._host.t('Reverse position on {0}', position.instrument),
289
+ () => this._deps.reversePosition(position.portfolioId!, position.instrumentId!, position.instrument!)));
290
+ return cell;
291
+ }
292
+
293
+ _actionButton(styleClass: string, iconClass: string, title: string, label: string, onClick: () => void): HTMLButtonElement {
294
+ const button = document.createElement('button');
295
+ button.type = 'button';
296
+ button.className = `bt-icon-btn ${styleClass}`;
297
+ button.title = title;
298
+ button.setAttribute('aria-label', label);
299
+ const icon = document.createElement('i');
300
+ icon.className = `bi ${iconClass}`;
301
+ button.appendChild(icon);
302
+ button.addEventListener('click', onClick);
303
+ return button;
304
+ }
305
+ }
@@ -0,0 +1,221 @@
1
+ // Trade history — multi-instance.
2
+ //
3
+ // Read-only table of executed trades for the active portfolio. Builds its own
4
+ // DOM (see `_buildRoot`) rather than cloning a <template> out of the page, so
5
+ // it can be constructed by any host that supplies a `TradingHost`.
6
+ //
7
+ // Which portfolio to load and what to load it through both come off the host's
8
+ // trading context rather than being read out of a singleton here: the panel
9
+ // renders what it is given, and the host decides what "the active portfolio"
10
+ // means.
11
+ //
12
+ // The table is `DataGrid` from `@stocksharp/grids`: the columns below are this
13
+ // blotter's single declaration and the <thead> is left empty for the grid to
14
+ // fill.
15
+ import { formatPrice, formatQty, formatTime } from './formatters.js';
16
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
17
+ import { ControlTypes } from './control-types.js';
18
+ import { TradingHost, assertHost } from './trading-host.js';
19
+ import type { TradeRow } from './trading-data.js';
20
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
21
+
22
+ /// The panel needs nothing beyond the host port: its data source, its
23
+ /// portfolio and its lifecycle all arrive through it.
24
+ export interface TradeHistoryDeps {
25
+ host: TradingHost;
26
+ }
27
+
28
+ export class TradeHistoryWidget {
29
+ static TYPE = ControlTypes.TradeHistory;
30
+
31
+ rootEl: HTMLElement;
32
+ bodyEl: HTMLElement | null;
33
+ // `//` rather than `///` from here down — see the note in positions-widget.
34
+ _host: TradingHost;
35
+ _closeBtn: HTMLElement | null;
36
+ _refreshBtn: HTMLElement | null;
37
+ _exportBtn: HTMLElement | null;
38
+ _rows: TradeRow[];
39
+ _grid: DataGrid<TradeRow> | null;
40
+
41
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: TradeHistoryDeps): TradeHistoryWidget {
42
+ // Assert before building: the markup below is localized through the
43
+ // host, so a missing host has to fail here rather than render a panel
44
+ // captioned with raw English keys.
45
+ const host = assertHost(deps?.host, 'TradeHistoryWidget');
46
+ const root = TradeHistoryWidget._buildRoot(host);
47
+ root.id = makePanelId(TradeHistoryWidget.TYPE);
48
+ hostEl.appendChild(root);
49
+ return new TradeHistoryWidget(root, state || {}, deps);
50
+ }
51
+
52
+ // The panel's markup. The host stylesheet reads this structure, and a
53
+ // docking host lifts `.panel-header`'s children into its tab strip.
54
+ static _buildRoot(host: TradingHost): HTMLElement {
55
+ const title = host.t('TradeHistory');
56
+ return makePanelRoot('trade-history-panel', title, [
57
+ makeElement('div', 'panel-header', {}, [
58
+ makeElement('span', '', {}, [title]),
59
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
60
+ ]),
61
+ makeElement('div', 'panel-body panel-body-with-rail', {}, [
62
+ makeElement('div', 'panel-body-content', {}, [
63
+ makeElement('table', 'terminal-table trade-history-table', { role: 'table', 'aria-label': host.t('TradeHistoryList') }, [
64
+ makeElement('thead', '', {}, []),
65
+ makeElement('tbody', 'trade-history-body', {}, []),
66
+ ]),
67
+ ]),
68
+ makeElement('div', 'panel-rail', { role: 'toolbar', 'aria-label': host.t('TradeHistoryActions') }, [
69
+ makeIconButton('bt-icon-btn panel-refresh-btn', host.t('Refresh'), 'bi-arrow-clockwise', {}),
70
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
71
+ ]),
72
+ ]),
73
+ ]);
74
+ }
75
+
76
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: TradeHistoryDeps) {
77
+ this._host = assertHost(deps?.host, 'TradeHistoryWidget');
78
+
79
+ this.rootEl = rootEl;
80
+ this.bodyEl = this.rootEl.querySelector('.trade-history-body');
81
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
82
+ this._refreshBtn = this.rootEl.querySelector('.panel-refresh-btn');
83
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
84
+
85
+ this._closeBtn?.addEventListener('click', (e) => {
86
+ e.preventDefault();
87
+ this.dispose();
88
+ this._host.close();
89
+ });
90
+ this._refreshBtn?.addEventListener('click', (e) => {
91
+ e.preventDefault();
92
+ void this.refresh();
93
+ });
94
+
95
+ this._exportBtn?.addEventListener('click', (e) => {
96
+ e.preventDefault();
97
+ this._export();
98
+ });
99
+
100
+ this._rows = [];
101
+
102
+ const head = this.rootEl.querySelector('.trade-history-table thead');
103
+ this._grid = head && this.bodyEl
104
+ ? new DataGrid<TradeRow>({
105
+ head: head as HTMLElement,
106
+ body: this.bodyEl,
107
+ columns: this._columns(),
108
+ // Most recent fill on top by default — the natural way to read a trade blotter.
109
+ defaultSort: { col: 'time', dir: 'desc' },
110
+ rowKey: (t) => String(t.id),
111
+ emptyText: this._host.t('No trade history'),
112
+ })
113
+ : null;
114
+
115
+ this._host.register(this);
116
+ }
117
+
118
+ dispose(): void {
119
+ this._host.unregister(this);
120
+ try { this.rootEl.remove(); } catch { /* already detached */ }
121
+ }
122
+
123
+ /// Reload against whichever portfolio the host says is active right now.
124
+ ///
125
+ /// Two guards, and they are not the same one. No portfolio means there is
126
+ /// nothing to show — a guest session has none. A portfolio the host will
127
+ /// not `allow` reading means the session cannot make the call at all: this
128
+ /// is somebody's executed trades, so a page that has a portfolio id lying
129
+ /// around but no live credential must not fire the request and collect a
130
+ /// 401 for it. The panel asks the host rather than inspecting a token,
131
+ /// because what counts as a usable session is the host's to know.
132
+ ///
133
+ /// Order matters: the portfolio check runs first, so a session with no
134
+ /// portfolio is simply quiet — `allow` is the one that may put a sign-in
135
+ /// prompt on screen, and reaching it means the page did have an account to
136
+ /// load.
137
+ async refresh(): Promise<void> {
138
+ const pf = this._host.trading.portfolioId();
139
+ if (!pf || !this._grid) return;
140
+ if (!this._host.allow('load trade history')) return;
141
+ try {
142
+ this._rows = await this._host.trading.api.getExecutions(pf, null, 200) || [];
143
+ this._grid.setRows(this._rows);
144
+ } catch (err) {
145
+ // Through the port, not the console: this is the diagnostic the
146
+ // user cannot see and support has to, and routing it here is also
147
+ // what makes it assertable.
148
+ this._host.log(`TradeHistoryWidget: failed to load trade history: ${err}`);
149
+ }
150
+ }
151
+
152
+ // Export the table to .xlsx in the currently rendered (sorted) order.
153
+ _export(): void {
154
+ this._grid?.download('trades', this._host.t('TradeHistory'));
155
+ }
156
+
157
+ // The blotter's single column declaration. The two id columns read `#42` on
158
+ // screen and export the bare number, so the sheet stays sortable as a number.
159
+ _columns(): GridColumn<TradeRow>[] {
160
+ // `label`, not `t` — every row lambda below already binds `t` as the
161
+ // trade it is rendering, and a translator sharing that name would be
162
+ // shadowed inside exactly the callbacks that read it.
163
+ const label = (key: string) => this._host.t(key);
164
+ const presentation = this._host.presentation;
165
+ return [
166
+ {
167
+ key: 'id',
168
+ header: label('ID'),
169
+ exportable: true,
170
+ value: (t) => t.id,
171
+ render: (t) => '#' + t.id,
172
+ cellClass: () => 'mono-id',
173
+ },
174
+ {
175
+ key: 'time',
176
+ header: label('Time'),
177
+ exportable: true,
178
+ value: (t) => t.executedAt || t.time,
179
+ render: (t) => formatTime(t.executedAt || t.time),
180
+ exportValue: (t) => formatTime(t.executedAt || t.time),
181
+ },
182
+ {
183
+ key: 'symbol',
184
+ header: label('Sym'),
185
+ exportable: true,
186
+ value: (t) => t.instrumentSymbol || t.symbol,
187
+ },
188
+ {
189
+ key: 'side',
190
+ header: label('Side'),
191
+ exportable: true,
192
+ value: (t) => t.side,
193
+ render: (t) => presentation.sideText(t.side!),
194
+ cellClass: (t) => presentation.sideClass(t.side!),
195
+ exportValue: (t) => presentation.sideText(t.side!),
196
+ },
197
+ {
198
+ key: 'quantity',
199
+ header: label('Qty'),
200
+ exportable: true,
201
+ value: (t) => t.quantity,
202
+ render: (t) => formatQty(t.quantity),
203
+ },
204
+ {
205
+ key: 'price',
206
+ header: label('Price'),
207
+ exportable: true,
208
+ value: (t) => t.price,
209
+ render: (t) => formatPrice(t.price),
210
+ },
211
+ {
212
+ key: 'order',
213
+ header: label('Order'),
214
+ exportable: true,
215
+ value: (t) => t.order || t.orderId,
216
+ render: (t) => '#' + (t.order || t.orderId || ''),
217
+ cellClass: () => 'mono-id',
218
+ },
219
+ ];
220
+ }
221
+ }
@@ -0,0 +1,121 @@
1
+ // The rows the controls render.
2
+ //
3
+ // These are wire shapes, not domain models: the host fetches them from
4
+ // whatever backend it has and hands them over, and this package only reads
5
+ // them. That is why every field is optional. A server that omits `realizedPnl`
6
+ // on a flat position, a snapshot row that predates a field, an optimistic
7
+ // client-side insert carrying half a fill — all of them are ordinary here, and
8
+ // every control already guards with `?? 0` / `|| ''` at the point of use. A
9
+ // required field would only move that guard to the host, which cannot honour it
10
+ // either.
11
+ //
12
+ // Two fields are deliberately unions rather than enums. `side` and `status`
13
+ // arrive in more than one spelling depending on which endpoint produced the row
14
+ // (a numeric StockSharp enum from the socket, a name from the DTO, an uppercase
15
+ // name from the history endpoint), and normalising them is the host's job —
16
+ // `TradingPresentation` is where that knowledge lives. Narrowing the type here
17
+ // would only force a cast at every call site that is already correct.
18
+ //
19
+ // Every numeric field is `number | null` as well as optional, for the same
20
+ // reason: an absent price reaches us as an omitted key from one endpoint and as
21
+ // an explicit null from another, both meaning "not set". Saying so once here is
22
+ // what lets the read sites stay the plain `?? 0` / `|| ''` they already were.
23
+ //
24
+ // This module has no imports and no behaviour.
25
+
26
+ /// Buy or sell, in any of the spellings the wire uses: `0`/`1`, `'Buy'`/`'Sell'`
27
+ /// or `'BUY'`/`'SELL'`.
28
+ export type OrderSide = number | string;
29
+
30
+ /// A StockSharp `OrderTypes` value (`0` limit, `1` market, `2` conditional) or
31
+ /// its name.
32
+ export type OrderType = number | string;
33
+
34
+ /// A StockSharp `OrderStates` value (see `OrderStates` in
35
+ /// `active-orders-widget.ts`) or its name.
36
+ export type OrderStatus = number | string;
37
+
38
+ /// One open position in a portfolio.
39
+ export interface PositionRow {
40
+ portfolioId?: number | null;
41
+ /// Numeric instrument id where the source has one. Rows from an unsaved
42
+ /// snapshot may carry only `instrument`, which is why the identity used for
43
+ /// matching falls back to it.
44
+ instrumentId?: number | null;
45
+ instrument?: string;
46
+ quantity?: number | null;
47
+ avgPrice?: number | null;
48
+ currentPrice?: number | null;
49
+ unrealizedPnl?: number | null;
50
+ realizedPnl?: number | null;
51
+ }
52
+
53
+ /// The cash side of a portfolio, rendered as a pinned row above the positions.
54
+ export interface BalanceRow {
55
+ available?: number | null;
56
+ locked?: number | null;
57
+ total?: number | null;
58
+ }
59
+
60
+ /// One live or recently terminal order.
61
+ export interface OrderRow {
62
+ /// The register transaction id. This is what cancel, replace and inline edit
63
+ /// address an order by, and what the grid keys rows on.
64
+ id?: number | null;
65
+ /// Per-user friendly counter shown in the ID column. Absent on rows cached
66
+ /// from before the counter shipped.
67
+ localId?: number | null;
68
+ instrument?: string;
69
+ side?: OrderSide;
70
+ type?: OrderType;
71
+ quantity?: number | null;
72
+ /// The unfilled remainder. A control that shows what is still resting on
73
+ /// the book reads this and falls back to `quantity` — a snapshot row taken
74
+ /// before anything filled carries only the latter.
75
+ balance?: number | null;
76
+ limitPrice?: number | null;
77
+ stopPrice?: number | null;
78
+ status?: OrderStatus;
79
+ /// Venue text for a rejection. Some adapters wrap it in a JSON blob — see
80
+ /// `cleanRejectReason`.
81
+ rejectReason?: string;
82
+ }
83
+
84
+ /// One executed trade.
85
+ export interface TradeRow {
86
+ id?: number | null;
87
+ /// Execution time. `executedAt` is what the API returns; `time` is what a
88
+ /// socket fill carries, and the control reads whichever is present.
89
+ executedAt?: string;
90
+ time?: string;
91
+ instrumentSymbol?: string;
92
+ symbol?: string;
93
+ side?: OrderSide;
94
+ quantity?: number | null;
95
+ price?: number | null;
96
+ /// The order this fill belongs to, under either of the two names the
97
+ /// endpoints use.
98
+ order?: number | null;
99
+ orderId?: number | null;
100
+ }
101
+
102
+ /// One tradable instrument, as the instrument search returns it.
103
+ export interface InstrumentRow {
104
+ /// Qualified symbol — `BTC@IMEX`. The display form drops the venue.
105
+ symbol?: string;
106
+ name?: string;
107
+ exchange?: string;
108
+ /// Admin-assigned grouping. The watchlist turns the distinct values into
109
+ /// filter tabs, so the tab set is configured rather than hardcoded.
110
+ category?: string;
111
+ }
112
+
113
+ /// What a control has computed about a symbol from the live tape. Handed to the
114
+ /// host's ticker sink as-is.
115
+ export interface QuoteStats {
116
+ lastPrice?: number | null;
117
+ /// First price observed this session-day, the change percentage is measured
118
+ /// from.
119
+ baseline?: number | null;
120
+ chgPct?: number | null;
121
+ }