@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,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';
|