@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.
- package/LICENSE +30 -0
- package/NOTICE +21 -0
- package/README.md +240 -0
- package/dist/esm/active-orders-widget.js +382 -0
- package/dist/esm/active-orders-widget.js.map +1 -0
- package/dist/esm/control-types.js +23 -0
- package/dist/esm/control-types.js.map +1 -0
- package/dist/esm/dom.js +55 -0
- package/dist/esm/dom.js.map +1 -0
- package/dist/esm/formatters.js +69 -0
- package/dist/esm/formatters.js.map +1 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/positions-widget.js +258 -0
- package/dist/esm/positions-widget.js.map +1 -0
- package/dist/esm/trade-history-widget.js +197 -0
- package/dist/esm/trade-history-widget.js.map +1 -0
- package/dist/esm/trading-data.js +26 -0
- package/dist/esm/trading-data.js.map +1 -0
- package/dist/esm/trading-host.js +79 -0
- package/dist/esm/trading-host.js.map +1 -0
- package/dist/esm/watchlist-widget.js +481 -0
- package/dist/esm/watchlist-widget.js.map +1 -0
- package/dist/sstradingcontrols.js +1740 -0
- package/dist/sstradingcontrols.js.map +7 -0
- package/dist/types/active-orders-widget.d.ts +52 -0
- package/dist/types/active-orders-widget.d.ts.map +1 -0
- package/dist/types/control-types.d.ts +8 -0
- package/dist/types/control-types.d.ts.map +1 -0
- package/dist/types/dom.d.ts +6 -0
- package/dist/types/dom.d.ts.map +1 -0
- package/dist/types/formatters.d.ts +8 -0
- package/dist/types/formatters.d.ts.map +1 -0
- package/dist/types/index.d.ts +16 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/positions-widget.d.ts +37 -0
- package/dist/types/positions-widget.d.ts.map +1 -0
- package/dist/types/trade-history-widget.d.ts +25 -0
- package/dist/types/trade-history-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +55 -0
- package/dist/types/trading-data.d.ts.map +1 -0
- package/dist/types/trading-host.d.ts +62 -0
- package/dist/types/trading-host.d.ts.map +1 -0
- package/dist/types/watchlist-widget.d.ts +60 -0
- package/dist/types/watchlist-widget.d.ts.map +1 -0
- package/package.json +129 -0
- package/src/active-orders-widget.ts +411 -0
- package/src/control-types.ts +24 -0
- package/src/dom.ts +68 -0
- package/src/formatters.ts +72 -0
- package/src/index.ts +52 -0
- package/src/positions-widget.ts +305 -0
- package/src/trade-history-widget.ts +221 -0
- package/src/trading-data.ts +121 -0
- package/src/trading-host.ts +300 -0
- package/src/watchlist-widget.ts +513 -0
- package/styles/theme.css +86 -0
- package/styles/trading-controls.css +469 -0
- 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
|
+
}
|