@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,411 @@
1
+ // Active orders — multi-instance.
2
+ //
3
+ // Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
4
+ // the page, so it can be constructed by any host that supplies a `TradingHost`.
5
+ // Every instance shares the same data source (a host refresh / an order update
6
+ // off the socket); each update fans through the host's broadcast so duplicates
7
+ // stay in sync.
8
+ //
9
+ // The table itself is `DataGrid` from `@stocksharp/grids`: the columns below are
10
+ // the single declaration of this blotter — caption, value, rendering, colour
11
+ // class and export all live together, and the <thead> is left empty for the
12
+ // grid to fill. Cells that hold a control return a real Node with its own
13
+ // listener, so nothing here reaches a global through an inline onclick
14
+ // attribute; the callbacks arrive as deps instead.
15
+ import { cleanRejectReason, formatPrice, formatQty } 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 { OrderRow } from './trading-data.js';
20
+ import { DataGrid, GridColumn } from '@stocksharp/grids/source/data-grid';
21
+
22
+ /// StockSharp order states as they arrive on the wire. The blotter branches on
23
+ /// them for row colour, for whether a cell can be edited in place and for what
24
+ /// the × button does, so they are named once here instead of appearing as bare
25
+ /// numbers at each branch.
26
+ export const OrderStates = {
27
+ PendingRisk: 1,
28
+ Sent: 2,
29
+ Active: 3,
30
+ PartiallyFilled: 4,
31
+ Filled: 5,
32
+ Rejected: 6,
33
+ Cancelled: 7,
34
+ } as const;
35
+
36
+ /// What the blotter needs beyond the host port. All required: a panel that
37
+ /// cannot cancel, modify or reload its orders is not this panel.
38
+ export interface ActiveOrdersDeps {
39
+ host: TradingHost;
40
+ cancelOrder(orderId: number): void;
41
+ dismissOrder(orderId: number): void;
42
+ editOrderField(orderId: number, field: string): void;
43
+ replaceOrder(orderId: number, quantity: number, limitPrice: number, stopPrice: number): void;
44
+ cancelAllOrders(): void;
45
+ refreshOrders(): void;
46
+ }
47
+
48
+ export class ActiveOrdersWidget {
49
+ static TYPE = ControlTypes.ActiveOrders;
50
+
51
+ rootEl: HTMLElement;
52
+ el: HTMLElement | null;
53
+ // `//` rather than `///` from here down — see the note in positions-widget.
54
+ _host: TradingHost;
55
+ _deps: ActiveOrdersDeps;
56
+ _closeBtn: HTMLElement | null;
57
+ _cancelAllBtn: HTMLElement | null;
58
+ _refreshBtn: HTMLElement | null;
59
+ _exportBtn: HTMLElement | null;
60
+ _orders: OrderRow[];
61
+ _grid: DataGrid<OrderRow> | null;
62
+
63
+ static create(hostEl: HTMLElement, state: Record<string, unknown>, deps: ActiveOrdersDeps): ActiveOrdersWidget {
64
+ // Assert before building: the markup below is localized through the
65
+ // host, so a missing host has to fail here rather than render a panel
66
+ // captioned with raw English keys.
67
+ const host = assertHost(deps?.host, 'ActiveOrdersWidget');
68
+ const root = ActiveOrdersWidget._buildRoot(host);
69
+ root.id = makePanelId(ActiveOrdersWidget.TYPE);
70
+ hostEl.appendChild(root);
71
+ return new ActiveOrdersWidget(root, state || {}, deps);
72
+ }
73
+
74
+ // The panel's markup. The host stylesheet reads this structure, and a
75
+ // docking host lifts `.panel-header`'s children into its tab strip.
76
+ //
77
+ // The rail's first two buttons are the ones that could not come across
78
+ // unchanged from the terminal's Razor template: they were inline
79
+ // `onclick="terminalApp.cancelAll()"` / `refreshOrders()`, references to a
80
+ // global this control must not have. They carry `.panel-cancel-all-btn` and
81
+ // `.panel-refresh-btn` now — the latter the name the other blotters' rails
82
+ // already give the identical button — and are wired to deps. Cancel-all
83
+ // keeps a distinct accessible name from its tooltip, which is why it states
84
+ // `aria-label` rather than taking the tooltip for both.
85
+ static _buildRoot(host: TradingHost): HTMLElement {
86
+ return makePanelRoot('active-orders-panel', host.t('ActiveOrders'), [
87
+ makeElement('div', 'panel-header', {}, [
88
+ makeElement('span', '', {}, [host.t('OpenOrders')]),
89
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
90
+ ]),
91
+ makeElement('div', 'panel-body panel-body-with-rail', {}, [
92
+ makeElement('div', 'panel-body-content', {}, [
93
+ makeElement('table', 'terminal-table active-orders-table', { role: 'table', 'aria-label': host.t('ActiveOrdersList') }, [
94
+ makeElement('thead', '', {}, []),
95
+ makeElement('tbody', 'active-orders-body', {}, []),
96
+ ]),
97
+ ]),
98
+ makeElement('div', 'panel-rail', { role: 'toolbar', 'aria-label': host.t('ActiveOrdersActions') }, [
99
+ makeIconButton('bt-icon-btn bt-icon-cancel-all panel-cancel-all-btn', host.t('CancelAll'), 'bi-x-circle',
100
+ { 'aria-label': host.t('CancelAllOrders') }),
101
+ makeIconButton('bt-icon-btn panel-refresh-btn', host.t('Refresh'), 'bi-arrow-clockwise', {}),
102
+ makeIconButton('bt-icon-btn panel-export-btn', host.t('ExportToExcel'), 'bi-file-earmark-spreadsheet', {}),
103
+ ]),
104
+ ]),
105
+ ]);
106
+ }
107
+
108
+ constructor(rootEl: HTMLElement, _state: Record<string, unknown>, deps: ActiveOrdersDeps) {
109
+ this._host = assertHost(deps?.host, 'ActiveOrdersWidget');
110
+ for (const name of ['cancelOrder', 'dismissOrder', 'editOrderField', 'replaceOrder', 'cancelAllOrders', 'refreshOrders'] as const) {
111
+ if (typeof deps?.[name] !== 'function')
112
+ throw new Error(`ActiveOrdersWidget: dep "${name}" is required`);
113
+ }
114
+
115
+ this.rootEl = rootEl;
116
+ this._deps = deps;
117
+ this.el = this.rootEl.querySelector('.active-orders-body');
118
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
119
+ this._cancelAllBtn = this.rootEl.querySelector('.panel-cancel-all-btn');
120
+ this._refreshBtn = this.rootEl.querySelector('.panel-refresh-btn');
121
+ this._exportBtn = this.rootEl.querySelector('.panel-export-btn');
122
+ this._orders = [];
123
+
124
+ this._closeBtn?.addEventListener('click', (e) => {
125
+ e.preventDefault();
126
+ this._host.close();
127
+ });
128
+
129
+ this._cancelAllBtn?.addEventListener('click', (e) => {
130
+ e.preventDefault();
131
+ this._deps.cancelAllOrders();
132
+ });
133
+
134
+ this._refreshBtn?.addEventListener('click', (e) => {
135
+ e.preventDefault();
136
+ this._deps.refreshOrders();
137
+ });
138
+
139
+ this._exportBtn?.addEventListener('click', (e) => {
140
+ e.preventDefault();
141
+ this._export();
142
+ });
143
+
144
+ const head = this.rootEl.querySelector('.active-orders-table thead');
145
+ this._grid = head && this.el
146
+ ? new DataGrid<OrderRow>({
147
+ head: head as HTMLElement,
148
+ body: this.el,
149
+ columns: this._columns(),
150
+ // Newest order on top by default (localId is monotonic) — the resting view puts the
151
+ // user's most recent actions where they look, instead of raw cache order. Natural
152
+ // array order is "newest first" too (deltas unshift), but only by accident; any
153
+ // header click re-renders the same array through the sorter.
154
+ defaultSort: { col: 'id', dir: 'desc' },
155
+ // The register tx, not the friendly localId shown in the ID column: it is what
156
+ // cancel / replace / inline edit address an order by.
157
+ rowKey: (o) => String(o.id),
158
+ emptyText: this._host.t('NoActiveOrders'),
159
+ // Terminal rows are greyed out so the live ones stay visually prominent.
160
+ rowClass: (o) => ActiveOrdersWidget._rowClass(o),
161
+ })
162
+ : null;
163
+
164
+ this._host.register(this);
165
+ }
166
+
167
+ dispose(): void {
168
+ this._host.unregister(this);
169
+ try { this.rootEl.remove(); } catch { /* already detached */ }
170
+ }
171
+
172
+ update(orders: OrderRow[]): void {
173
+ this._orders = orders || [];
174
+ this._grid?.setRows(this._orders);
175
+ }
176
+
177
+ // Export the table to .xlsx in the currently rendered (sorted) order, with the
178
+ // same localized side/type/status texts the user sees on screen — the columns
179
+ // carry both forms, so the sheet cannot drift from the table.
180
+ _export(): void {
181
+ this._grid?.download('orders', this._host.t('OpenOrders'));
182
+ }
183
+
184
+ /// Apply a single order delta from the feed. Insert if new, merge if known.
185
+ /// Nothing drops out of the list: Filled and Cancelled rows stay so the user
186
+ /// can watch an order's transitions instead of having a row vanish, and a
187
+ /// Rejected row stays so its reason can be read on hover and dismissed
188
+ /// explicitly through the × button.
189
+ applyDelta(order: OrderRow): void {
190
+ if (!order || order.id == null) return;
191
+ const idx = this._orders.findIndex(o => o.id === order.id);
192
+ if (idx >= 0)
193
+ this._orders[idx] = { ...this._orders[idx], ...order };
194
+ else
195
+ this._orders.unshift(order);
196
+ this.update(this._orders);
197
+ }
198
+
199
+ /// Remove a row from the local view without touching the server. Used by the
200
+ /// × button on a terminal order (nothing left to cancel), and in any other
201
+ /// client-side dismiss path.
202
+ removeOrder(orderId: number): void {
203
+ const idx = this._orders.findIndex(o => o.id === orderId);
204
+ if (idx >= 0) {
205
+ this._orders.splice(idx, 1);
206
+ this.update(this._orders);
207
+ }
208
+ }
209
+
210
+ getOrder(orderId: number): OrderRow | undefined {
211
+ return this._orders.find(o => o.id === orderId);
212
+ }
213
+
214
+ startInlineEdit(orderId: number, field: 'quantity' | 'limitPrice' | 'stopPrice'): void {
215
+ const order = this.getOrder(orderId);
216
+ if (!order) return;
217
+ // Addressed by the grid's row key (the register tx) and the column key, so
218
+ // the edit lands on the right cell whatever the sort is. A lookup that
219
+ // scanned the rendered ID column for the order id would never match, because
220
+ // that column shows the friendly counter.
221
+ const cell = this._grid?.cellElement(String(orderId), field);
222
+ if (!cell || cell.querySelector('input')) return;
223
+ const currentValue = order[field];
224
+ const input = document.createElement('input');
225
+ input.type = 'number';
226
+ input.className = 'inline-edit-input';
227
+ input.value = String(currentValue ?? '');
228
+ input.step = field === 'quantity' ? '1' : '0.01';
229
+ input.min = field === 'quantity' ? '1' : '0.01';
230
+ cell.textContent = '';
231
+ cell.appendChild(input);
232
+ input.focus();
233
+ input.select();
234
+ const commit = () => {
235
+ const newVal = parseFloat(input.value);
236
+ if (isNaN(newVal) || newVal <= 0) { this.update(this._orders); return; }
237
+ if (newVal === currentValue) { this.update(this._orders); return; }
238
+ const updated = { quantity: order.quantity, limitPrice: order.limitPrice, stopPrice: order.stopPrice };
239
+ updated[field] = newVal;
240
+ this._deps.replaceOrder(orderId, updated.quantity!, updated.limitPrice!, updated.stopPrice!);
241
+ };
242
+ input.addEventListener('blur', commit);
243
+ input.addEventListener('keydown', (e) => {
244
+ if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
245
+ if (e.key === 'Escape') { this.update(this._orders); }
246
+ });
247
+ }
248
+
249
+ // The blotter's single column declaration: what the header says, what the
250
+ // grid sorts on, what the cell shows, how it is coloured, and what reaches
251
+ // the exported sheet.
252
+ _columns(): GridColumn<OrderRow>[] {
253
+ // `label`, not `t` — every row lambda below binds `o` for the order,
254
+ // but keeping the translator under a name of its own reads clearer
255
+ // next to the presentation object it sits beside.
256
+ const label = (key: string) => this._host.t(key);
257
+ const presentation = this._host.presentation;
258
+ return [
259
+ {
260
+ key: 'id',
261
+ header: label('ID'),
262
+ exportable: true,
263
+ // Per-user friendly counter (1, 2, 3, …) issued by the host. `o.id`
264
+ // keeps the register tx for cancel / replace routing; `o.localId` is
265
+ // just the badge the user wants to see. Rows cached from before the
266
+ // counter shipped have no localId — fall back to id so they don't go
267
+ // blank until the next snapshot replay reassigns one.
268
+ value: (o) => o.localId ?? o.id,
269
+ },
270
+ {
271
+ key: 'instrument',
272
+ header: label('Sym'),
273
+ exportable: true,
274
+ value: (o) => o.instrument,
275
+ },
276
+ {
277
+ key: 'side',
278
+ header: label('Side'),
279
+ exportable: true,
280
+ value: (o) => o.side,
281
+ render: (o) => presentation.sideText(o.side!),
282
+ cellClass: (o) => presentation.sideClass(o.side!),
283
+ exportValue: (o) => presentation.sideText(o.side!),
284
+ },
285
+ {
286
+ key: 'type',
287
+ header: label('Type'),
288
+ exportable: true,
289
+ value: (o) => o.type,
290
+ render: (o) => presentation.typeText(o.type!, o.limitPrice!, o.stopPrice!),
291
+ exportValue: (o) => presentation.typeText(o.type!, o.limitPrice!, o.stopPrice!),
292
+ },
293
+ {
294
+ key: 'quantity',
295
+ header: label('Qty'),
296
+ exportable: true,
297
+ value: (o) => o.quantity,
298
+ render: (o) => formatQty(o.quantity),
299
+ cellClass: (o) => ActiveOrdersWidget._editableClass(o, 'quantity'),
300
+ bindCell: (td, o) => this._bindInlineEdit(td, o, 'quantity'),
301
+ },
302
+ {
303
+ key: 'limitPrice',
304
+ header: label('Price'),
305
+ exportable: true,
306
+ value: (o) => o.limitPrice,
307
+ render: (o) => o.limitPrice ? formatPrice(o.limitPrice) : label('MKT'),
308
+ cellClass: (o) => ActiveOrdersWidget._editableClass(o, 'limitPrice'),
309
+ bindCell: (td, o) => this._bindInlineEdit(td, o, 'limitPrice'),
310
+ exportValue: (o) => o.limitPrice ?? label('MKT'),
311
+ },
312
+ {
313
+ key: 'stopPrice',
314
+ header: label('Stop'),
315
+ exportable: true,
316
+ value: (o) => o.stopPrice,
317
+ render: (o) => o.stopPrice ? formatPrice(o.stopPrice) : '--',
318
+ cellClass: (o) => ActiveOrdersWidget._editableClass(o, 'stopPrice'),
319
+ bindCell: (td, o) => this._bindInlineEdit(td, o, 'stopPrice'),
320
+ exportValue: (o) => o.stopPrice ?? '',
321
+ },
322
+ {
323
+ key: 'status',
324
+ header: label('Status'),
325
+ exportable: true,
326
+ value: (o) => o.status,
327
+ render: (o) => this._statusCell(o),
328
+ exportValue: (o) => presentation.statusText(o.status!),
329
+ },
330
+ {
331
+ key: 'actions',
332
+ header: label('Actions'),
333
+ headerHidden: true,
334
+ exportable: false,
335
+ cellClass: () => 'position-actions',
336
+ render: (o) => this._actionButton(o),
337
+ },
338
+ ];
339
+ }
340
+
341
+ static _rowClass(order: OrderRow): string {
342
+ if (order.status === OrderStates.Filled) return 'order-filled';
343
+ if (order.status === OrderStates.Rejected) return 'order-rejected';
344
+ if (order.status === OrderStates.Cancelled) return 'order-cancelled';
345
+ return '';
346
+ }
347
+
348
+ // An order can still be modified while the venue holds it. A market order has
349
+ // no limit price and an unconditional order no stop price, so those cells have
350
+ // nothing to edit even then.
351
+ static _canEdit(order: OrderRow, field: 'quantity' | 'limitPrice' | 'stopPrice'): boolean {
352
+ const modifiable = order.status === OrderStates.Active || order.status === OrderStates.Sent;
353
+ return modifiable && (field === 'quantity' || !!order[field]);
354
+ }
355
+
356
+ static _editableClass(order: OrderRow, field: 'quantity' | 'limitPrice' | 'stopPrice'): string {
357
+ return ActiveOrdersWidget._canEdit(order, field) ? 'cell-editable' : '';
358
+ }
359
+
360
+ // Double-click to edit belongs to the whole cell rather than to a control
361
+ // inside it, which is why it is wired here instead of returned by render().
362
+ _bindInlineEdit(td: HTMLTableCellElement, order: OrderRow, field: 'quantity' | 'limitPrice' | 'stopPrice'): void {
363
+ if (!ActiveOrdersWidget._canEdit(order, field)) return;
364
+ td.addEventListener('dblclick', () => this._deps.editOrderField(order.id!, field));
365
+ }
366
+
367
+ // Status text, plus a hoverable icon carrying the rejection reason. Some
368
+ // venues wrap the human-readable rejection inside a JSON blob;
369
+ // cleanRejectReason extracts `.message` and falls back to the raw string.
370
+ // The reason goes on the element's title property, so no quote escaping is
371
+ // involved the way it was when this row was built as markup.
372
+ _statusCell(order: OrderRow): string | Node {
373
+ const text = this._host.presentation.statusText(order.status!);
374
+ const reason = order.status === OrderStates.Rejected ? cleanRejectReason(order.rejectReason) : '';
375
+ if (!reason) return text;
376
+
377
+ const cell = document.createDocumentFragment();
378
+ cell.appendChild(document.createTextNode(text + ' '));
379
+ const icon = document.createElement('i');
380
+ icon.className = 'bi bi-question-circle reject-reason-icon';
381
+ icon.title = reason;
382
+ cell.appendChild(icon);
383
+ return cell;
384
+ }
385
+
386
+ // The × button, wired to the controller through deps. Status decides what it
387
+ // means: Pending / Active is a real cancel over the wire, while Filled /
388
+ // Rejected / Cancelled have nothing left to cancel — the row is history kept
389
+ // around so the user can read its final state, so the button dismisses it
390
+ // locally.
391
+ _actionButton(order: OrderRow): Node {
392
+ const isTerminal = order.status === OrderStates.Filled
393
+ || order.status === OrderStates.Rejected
394
+ || order.status === OrderStates.Cancelled;
395
+ const title = isTerminal ? this._host.t('Dismiss') : this._host.t('Cancel order');
396
+
397
+ const button = document.createElement('button');
398
+ button.type = 'button';
399
+ button.className = 'bt-icon-btn bt-icon-cancel';
400
+ button.title = title;
401
+ button.setAttribute('aria-label', `${title} #${order.id}`);
402
+ const icon = document.createElement('i');
403
+ icon.className = 'bi bi-x-circle';
404
+ button.appendChild(icon);
405
+ button.addEventListener('click', () => {
406
+ if (isTerminal) this._deps.dismissOrder(order.id!);
407
+ else this._deps.cancelOrder(order.id!);
408
+ });
409
+ return button;
410
+ }
411
+ }
@@ -0,0 +1,24 @@
1
+ // The identifiers a host and a control agree on for each kind of control this
2
+ // package ships.
3
+ //
4
+ // These strings used to be spelled out independently in four places — a docking
5
+ // manager's panel table, its default layouts, each control's static TYPE
6
+ // literal and a Razor panels menu — so a typo in any one of them produced a
7
+ // panel that opened but never received data, with nothing to compare against.
8
+ //
9
+ // The VALUES are load-bearing and must not be edited. A host writes them into
10
+ // whatever it persists its layout as, so renaming one orphans every saved
11
+ // layout that mentions it.
12
+ //
13
+ // Only the kinds this package implements are named here. A host that has more
14
+ // control kinds of its own declares those itself and merges the two sets; that
15
+ // keeps every identifier declared exactly once, by whoever owns the control it
16
+ // names.
17
+ export const ControlTypes = {
18
+ Watchlist: 'watchlist',
19
+ ActiveOrders: 'activeOrders',
20
+ TradeHistory: 'tradeHistory',
21
+ Positions: 'positions',
22
+ } as const;
23
+
24
+ export type ControlType = typeof ControlTypes[keyof typeof ControlTypes];
package/src/dom.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Element construction for controls that build their own DOM.
2
+ //
3
+ // A control that clones a <template> out of the page it happens to live on
4
+ // cannot render anywhere else, so the markup has to move into the control. It
5
+ // then has to stay readable as markup, which four lines of createElement /
6
+ // className / setAttribute / appendChild per element does not.
7
+ //
8
+ // Nothing here has an import, touches a global or knows what a trading control
9
+ // is — it is a spelling of `document.createElement`, and the shape of the call
10
+ // mirrors the shape of the tag it replaces:
11
+ //
12
+ // <div class="panel-header"><span>Positions</span></div>
13
+ // makeElement('div', 'panel-header', {}, [makeElement('span', '', {}, ['Positions'])])
14
+
15
+ /// One element. `className` may be empty and `attrs` may be empty — both are
16
+ /// then left off entirely, so the result matches markup that never carried
17
+ /// them. Children are appended in order; a string child becomes a text node.
18
+ export function makeElement(
19
+ tag: string,
20
+ className: string,
21
+ attrs: Record<string, string>,
22
+ children: Array<Node | string>,
23
+ ): HTMLElement {
24
+ const element = document.createElement(tag);
25
+ if (className) element.className = className;
26
+ for (const [name, value] of Object.entries(attrs)) element.setAttribute(name, value);
27
+ for (const child of children)
28
+ element.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
29
+ return element;
30
+ }
31
+
32
+ /// A Bootstrap icon glyph — `<i class="bi bi-x"></i>`. Every icon in a control's
33
+ /// panel chrome is one of these, always with the `bi` base class.
34
+ export function makeIcon(iconClass: string): HTMLElement {
35
+ return makeElement('i', `bi ${iconClass}`, {}, []);
36
+ }
37
+
38
+ /// An icon button from the panel chrome: one glyph, and the same text as both
39
+ /// tooltip and accessible name. `className` carries the full class list
40
+ /// because the buttons differ by more than one modifier
41
+ /// (`bt-icon-btn bt-icon-cancel panel-close-btn`).
42
+ export function makeIconButton(
43
+ className: string,
44
+ label: string,
45
+ iconClass: string,
46
+ attrs: Record<string, string>,
47
+ ): HTMLButtonElement {
48
+ return makeElement('button', className, { title: label, 'aria-label': label, ...attrs },
49
+ [makeIcon(iconClass)]) as HTMLButtonElement;
50
+ }
51
+
52
+ /// The panel root every control builds: `.terminal-panel` plus the control's own
53
+ /// modifier class, carrying the region role and its accessible name.
54
+ ///
55
+ /// The structure is a contract in two directions and both are load-bearing. A
56
+ /// host stylesheet targets these class names, and a docking host may lift
57
+ /// `.panel-header`'s children into its own tab strip, which needs
58
+ /// `.terminal-panel` to be the root's only element child.
59
+ export function makePanelRoot(modifierClass: string, label: string, children: Array<Node | string>): HTMLElement {
60
+ return makeElement('div', `terminal-panel ${modifierClass}`, { role: 'region', 'aria-label': label }, children);
61
+ }
62
+
63
+ /// A unique element id for one control instance. Time plus a random tail: the
64
+ /// time alone collides when a host restores a saved layout, which creates every
65
+ /// panel inside one millisecond.
66
+ export function makePanelId(type: string): string {
67
+ return `panel-${type}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1000).toString(36)}`;
68
+ }
@@ -0,0 +1,72 @@
1
+ // Value formatters — pure functions over numbers, prices, quantities, times
2
+ // and one venue string.
3
+ //
4
+ // Nothing here reaches a translator, the DOM, a singleton or a window global,
5
+ // and the module has no import and no side effect. That is why it is its own
6
+ // file: this is the half of the presentation a control needs in ANY host. The
7
+ // other half — a host's own vocabulary for a side, a type, a status — is
8
+ // `TradingPresentation`, which is handed to a control rather than imported by
9
+ // it.
10
+ type Numeric = number | string | null | undefined;
11
+
12
+ /// Digits chosen from the magnitude of the price. A fixed two decimals
13
+ /// collapses distinct ticks of a penny instrument onto the same "0.44" —
14
+ /// on the Y axis, in the order book, in the price header, everywhere — while
15
+ /// six decimals on a $76,000 print is noise.
16
+ export function formatPrice(price: Numeric): string {
17
+ if (price == null || isNaN(Number(price))) return '--';
18
+ const n = Number(price);
19
+ const a = Math.abs(n);
20
+ let prec = 2;
21
+ if (a < 1) prec = 4;
22
+ if (a < 0.1) prec = 5;
23
+ if (a < 0.001) prec = 6;
24
+ if (a >= 1000) prec = 1;
25
+ if (a >= 10000) prec = 0;
26
+ return n.toFixed(prec);
27
+ }
28
+
29
+ /// Crypto quantities carry up to 8 decimals, so the cap is 8 rather than the
30
+ /// locale default of 3; trailing zeros are trimmed because a size of "1" reads
31
+ /// better than "1.00000000" in a ladder.
32
+ export function formatQty(qty: Numeric): string {
33
+ if (qty == null) return '--';
34
+ const n = Number(qty);
35
+ if (!isFinite(n)) return '--';
36
+ return n.toLocaleString(undefined, { maximumFractionDigits: 8 });
37
+ }
38
+
39
+ /// Wall-clock time of day, 24h, seconds included — a trade blotter is read by
40
+ /// eye against the tape, so the format is fixed rather than locale-driven.
41
+ export function formatTime(dateStr: string | number | Date | null | undefined): string {
42
+ const d = new Date(dateStr as string);
43
+ return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
44
+ }
45
+
46
+ /// A signed figure: profit carries an explicit `+` so a gain and a loss are
47
+ /// told apart by the number alone, without relying on the colour class.
48
+ export function formatPnl(pnl: Numeric): string {
49
+ if (pnl == null) return '--';
50
+ const val = Number(pnl);
51
+ const sign = val >= 0 ? '+' : '';
52
+ return sign + val.toFixed(2);
53
+ }
54
+
55
+ /// Some venue adapters hand us a wrapped error string like
56
+ /// "failed to complete request (err=Forbidden): {"code":40310000,"message":"cost basis must be >= ..."}"
57
+ /// The raw form is stored for audit, but users should see the human-readable
58
+ /// message only. Pull `message` out of the embedded JSON when present;
59
+ /// otherwise return the reason as-is.
60
+ export function cleanRejectReason(reason: string | null | undefined): string {
61
+ if (!reason) return '';
62
+ const i = reason.indexOf('{');
63
+ const j = reason.lastIndexOf('}');
64
+ if (i >= 0 && j > i) {
65
+ try {
66
+ const parsed = JSON.parse(reason.substring(i, j + 1));
67
+ if (parsed && typeof parsed.message === 'string' && parsed.message.length > 0)
68
+ return parsed.message;
69
+ } catch { /* fall through to raw */ }
70
+ }
71
+ return reason;
72
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ export { MarketDataLevels, PRESENTATION_CLASSES, assertHost } from './trading-host.js';
2
+
3
+ export type {
4
+ HostStore,
5
+ MarketDataClient,
6
+ MarketDataLevel,
7
+ TickerSink,
8
+ TradingApi,
9
+ TradingContext,
10
+ TradingControl,
11
+ TradingHost,
12
+ TradingPresentation,
13
+ } from './trading-host.js';
14
+
15
+ export type {
16
+ BalanceRow,
17
+ InstrumentRow,
18
+ OrderRow,
19
+ OrderSide,
20
+ OrderStatus,
21
+ OrderType,
22
+ PositionRow,
23
+ QuoteStats,
24
+ TradeRow,
25
+ } from './trading-data.js';
26
+
27
+ export { ControlTypes } from './control-types.js';
28
+
29
+ export type { ControlType } from './control-types.js';
30
+
31
+ export { cleanRejectReason, formatPnl, formatPrice, formatQty, formatTime } from './formatters.js';
32
+
33
+ // A host that renders its own chrome around these controls builds it with the
34
+ // same helpers, so the panel it wraps and the panel it wraps them in agree on
35
+ // class names.
36
+ export { makeElement, makeIcon, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
37
+
38
+ export { ActiveOrdersWidget, OrderStates } from './active-orders-widget.js';
39
+
40
+ export type { ActiveOrdersDeps } from './active-orders-widget.js';
41
+
42
+ export { PositionsWidget } from './positions-widget.js';
43
+
44
+ export type { PositionsDeps } from './positions-widget.js';
45
+
46
+ export { TradeHistoryWidget } from './trade-history-widget.js';
47
+
48
+ export type { TradeHistoryDeps } from './trade-history-widget.js';
49
+
50
+ export { WatchlistWidget } from './watchlist-widget.js';
51
+
52
+ export type { WatchlistDeps } from './watchlist-widget.js';