@stocksharp/trading-controls 0.1.0 → 1.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 (77) hide show
  1. package/README.md +92 -6
  2. package/dist/esm/active-orders-widget.js +9 -0
  3. package/dist/esm/active-orders-widget.js.map +1 -1
  4. package/dist/esm/control-types.js +3 -0
  5. package/dist/esm/control-types.js.map +1 -1
  6. package/dist/esm/grid-menu.js +54 -0
  7. package/dist/esm/grid-menu.js.map +1 -0
  8. package/dist/esm/index.js +8 -0
  9. package/dist/esm/index.js.map +1 -1
  10. package/dist/esm/order-entry-widget.js +654 -0
  11. package/dist/esm/order-entry-widget.js.map +1 -0
  12. package/dist/esm/orderbook-depth.js +51 -0
  13. package/dist/esm/orderbook-depth.js.map +1 -0
  14. package/dist/esm/orderbook-widget.js +792 -0
  15. package/dist/esm/orderbook-widget.js.map +1 -0
  16. package/dist/esm/positions-widget.js +9 -0
  17. package/dist/esm/positions-widget.js.map +1 -1
  18. package/dist/esm/trade-history-widget.js +8 -0
  19. package/dist/esm/trade-history-widget.js.map +1 -1
  20. package/dist/esm/tradefeed-aggregator.js +58 -0
  21. package/dist/esm/tradefeed-aggregator.js.map +1 -0
  22. package/dist/esm/tradefeed-bubbles.js +131 -0
  23. package/dist/esm/tradefeed-bubbles.js.map +1 -0
  24. package/dist/esm/tradefeed-widget.js +692 -0
  25. package/dist/esm/tradefeed-widget.js.map +1 -0
  26. package/dist/esm/trading-host.js +2 -0
  27. package/dist/esm/trading-host.js.map +1 -1
  28. package/dist/esm/watchlist-widget.js +2 -0
  29. package/dist/esm/watchlist-widget.js.map +1 -1
  30. package/dist/sstradingcontrols.js +3346 -25
  31. package/dist/sstradingcontrols.js.map +3 -3
  32. package/dist/types/active-orders-widget.d.ts.map +1 -1
  33. package/dist/types/control-types.d.ts +3 -0
  34. package/dist/types/control-types.d.ts.map +1 -1
  35. package/dist/types/grid-menu.d.ts +4 -0
  36. package/dist/types/grid-menu.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +13 -0
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/order-entry-widget.d.ts +86 -0
  40. package/dist/types/order-entry-widget.d.ts.map +1 -0
  41. package/dist/types/orderbook-depth.d.ts +19 -0
  42. package/dist/types/orderbook-depth.d.ts.map +1 -0
  43. package/dist/types/orderbook-widget.d.ts +105 -0
  44. package/dist/types/orderbook-widget.d.ts.map +1 -0
  45. package/dist/types/positions-widget.d.ts.map +1 -1
  46. package/dist/types/trade-history-widget.d.ts.map +1 -1
  47. package/dist/types/tradefeed-aggregator.d.ts +15 -0
  48. package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
  49. package/dist/types/tradefeed-bubbles.d.ts +39 -0
  50. package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
  51. package/dist/types/tradefeed-widget.d.ts +78 -0
  52. package/dist/types/tradefeed-widget.d.ts.map +1 -0
  53. package/dist/types/trading-data.d.ts +22 -0
  54. package/dist/types/trading-data.d.ts.map +1 -1
  55. package/dist/types/trading-host.d.ts +8 -0
  56. package/dist/types/trading-host.d.ts.map +1 -1
  57. package/dist/types/watchlist-widget.d.ts.map +1 -1
  58. package/package.json +21 -2
  59. package/screenshots/panels.jpg +0 -0
  60. package/src/active-orders-widget.ts +9 -0
  61. package/src/control-types.ts +3 -0
  62. package/src/grid-menu.ts +67 -0
  63. package/src/index.ts +31 -0
  64. package/src/order-entry-widget.ts +733 -0
  65. package/src/orderbook-depth.ts +99 -0
  66. package/src/orderbook-widget.ts +896 -0
  67. package/src/positions-widget.ts +9 -0
  68. package/src/trade-history-widget.ts +8 -0
  69. package/src/tradefeed-aggregator.ts +95 -0
  70. package/src/tradefeed-bubbles.ts +194 -0
  71. package/src/tradefeed-widget.ts +744 -0
  72. package/src/trading-data.ts +168 -121
  73. package/src/trading-host.ts +326 -300
  74. package/src/watchlist-widget.ts +2 -0
  75. package/styles/theme.css +23 -0
  76. package/styles/trading-controls.css +911 -0
  77. package/translation-keys.json +103 -2
@@ -0,0 +1,654 @@
1
+ // Order entry — a split Buy/Sell pad sharing one order-type selector.
2
+ //
3
+ // Builds its own DOM (see `_buildRoot`) rather than cloning a <template> out of
4
+ // the page it happens to be rendered on, and every element it reads is queried
5
+ // under `this.rootEl`, so a host may place more than one of these on a page.
6
+ //
7
+ // It fetches nothing. Reference prices arrive as the host sees them
8
+ // (`setLimitPrice`, `setBbo`), the size and price grid the venue will accept
9
+ // arrives as an `InstrumentSpec` (`setInstrument`), and the balance figures the
10
+ // percent buttons divide arrive through `setAvailable` / `setMaxQuantity`.
11
+ //
12
+ // Placing the order is deliberately not this control's to do: `TradingApi` is
13
+ // read-only, and everything that has to happen before the wire — the sign-in
14
+ // gate, the connection check, which portfolio the order belongs to — is the
15
+ // host's. So the pad validates, collects the form and hands both to the
16
+ // `submitOrder` dep.
17
+ //
18
+ // It renders no table, so it is the one control here that does not use
19
+ // `DataGrid`.
20
+ import { formatPrice, formatQty } from './formatters.js';
21
+ import { makeElement, makeIconButton, makePanelId, makePanelRoot } from './dom.js';
22
+ import { ControlTypes } from './control-types.js';
23
+ import { assertHost } from './trading-host.js';
24
+ /// The pad's two columns. Every method that addresses one takes this rather
25
+ /// than a wire side, because a column is the control's own vocabulary: which of
26
+ /// the two forms is being filled in, not what the resulting order will say.
27
+ export const OrderEntrySides = {
28
+ Buy: 'buy',
29
+ Sell: 'sell',
30
+ };
31
+ /// The order kinds the pad offers. The values are what `data-type` carries on
32
+ /// the tab buttons and what `toApiType` maps onto StockSharp's `OrderTypes`.
33
+ export const OrderEntryTypes = {
34
+ Market: 'market',
35
+ Limit: 'limit',
36
+ Stop: 'stop',
37
+ StopLimit: 'stoplimit',
38
+ };
39
+ // StockSharp `Sides`, the spelling `TradingPresentation.sideText` reads. The
40
+ // pad words neither button itself — the host owns the trading vocabulary.
41
+ const WIRE_SIDES = { buy: 0, sell: 1 };
42
+ // The percentage-of-maximum buttons under the quantity field.
43
+ const PERCENT_STEPS = [25, 50, 75, 100];
44
+ // Where each value lives inside a column. Structure, not looks: the inputs are
45
+ // reached through the field they belong to, so the markup carries one class per
46
+ // field rather than one per input.
47
+ const PRICE_INPUT = '.oe-field-price input';
48
+ const STOP_INPUT = '.oe-field-stop input';
49
+ const QTY_INPUT = '.oe-field-qty input';
50
+ const TP_INPUT = '.oe-field-tp input';
51
+ const SL_INPUT = '.oe-field-sl input';
52
+ const TPSL_CHECK = '.oe-tpsl-toggle input';
53
+ const AVAILABLE_VALUE = '.oe-avbl .oe-value';
54
+ const TOTAL_VALUE = '.oe-total .oe-value';
55
+ const MAX_VALUE = '.oe-foot .oe-value';
56
+ // The value fields, in the order a column shows them.
57
+ const VALUE_INPUTS = [PRICE_INPUT, STOP_INPUT, QTY_INPUT, TP_INPUT, SL_INPUT];
58
+ export class OrderEntryWidget {
59
+ static create(hostEl, state, deps) {
60
+ // Assert before building: the markup below is localized through the
61
+ // host, so a missing host has to fail here rather than render a pad
62
+ // captioned with raw English keys.
63
+ const host = assertHost(deps?.host, 'OrderEntryWidget');
64
+ const root = OrderEntryWidget._buildRoot(host);
65
+ root.id = makePanelId(OrderEntryWidget.TYPE);
66
+ hostEl.appendChild(root);
67
+ return new OrderEntryWidget(root, state || {}, deps);
68
+ }
69
+ // The pad's markup. The host stylesheet reads this structure, and a docking
70
+ // host lifts `.panel-header`'s children into its tab strip.
71
+ //
72
+ // There is no `+` button in the header: a second pad on the same portfolio
73
+ // is a host decision about the account, not a gesture the form offers.
74
+ static _buildRoot(host) {
75
+ const title = host.t('OrderEntry');
76
+ return makePanelRoot('order-entry-panel', title, [
77
+ makeElement('div', 'panel-header', {}, [
78
+ makeElement('span', '', {}, [title]),
79
+ makeIconButton('bt-icon-btn bt-icon-cancel panel-close-btn', host.t('ClosePanel'), 'bi-x', { type: 'button' }),
80
+ ]),
81
+ makeElement('div', 'order-entry-content', {}, [
82
+ OrderEntryWidget._buildTypeTabs(host),
83
+ makeElement('div', 'order-entry-split', {}, [
84
+ OrderEntryWidget._buildColumn(host, OrderEntrySides.Buy),
85
+ OrderEntryWidget._buildColumn(host, OrderEntrySides.Sell),
86
+ ]),
87
+ ]),
88
+ ]);
89
+ }
90
+ // Two pairs of type buttons, so each pair lines up with the column beneath
91
+ // it. Which one is active is decided by `setOrderType`, from the constructor
92
+ // and from a later click alike.
93
+ static _buildTypeTabs(host) {
94
+ const tab = (type, caption) => makeElement('button', 'btn-ot', { type: 'button', 'data-type': type, 'aria-pressed': 'false' }, [caption]);
95
+ return makeElement('div', 'order-type-tabs', { role: 'group', 'aria-label': host.t('OrderType') }, [
96
+ makeElement('div', 'ot-pair', {}, [
97
+ tab(OrderEntryTypes.Market, host.t('Market')),
98
+ tab(OrderEntryTypes.Limit, host.t('Limit')),
99
+ ]),
100
+ makeElement('div', 'ot-pair', {}, [
101
+ tab(OrderEntryTypes.Stop, host.t('Stop')),
102
+ tab(OrderEntryTypes.StopLimit, host.t('StopLimit')),
103
+ ]),
104
+ ]);
105
+ }
106
+ // One column. The modifier class and the submit button's colour class are
107
+ // assigned as whole literals rather than composed, so the style contract can
108
+ // read this control's class list out of the source.
109
+ static _buildColumn(host, side) {
110
+ const isBuy = side === OrderEntrySides.Buy;
111
+ const sideText = host.presentation.sideText(WIRE_SIDES[side]);
112
+ const maxLabel = isBuy ? host.t('Max Buy') : host.t('Max Sell');
113
+ // The shortcut is the host's, so its letter is a translated string like
114
+ // any other caption rather than a key typed in here.
115
+ const hotkey = isBuy ? host.t('BuyHotkey') : host.t('SellHotkey');
116
+ const submit = makeElement('button', 'btn-oe-submit', { type: 'button', 'aria-label': sideText }, [
117
+ sideText,
118
+ makeElement('span', 'hotkey-hint', {}, [`[${hotkey}]`]),
119
+ ]);
120
+ if (isBuy)
121
+ submit.className = 'btn-oe-submit btn-buy';
122
+ else
123
+ submit.className = 'btn-oe-submit btn-sell';
124
+ const column = makeElement('div', 'oe-col', { 'data-side': side }, [
125
+ makeElement('div', 'oe-avbl', {}, [
126
+ makeElement('span', 'oe-label', {}, [host.t('Avbl')]),
127
+ makeElement('span', 'oe-value', {}, ['--']),
128
+ ]),
129
+ makeElement('div', 'oe-field oe-field-price', {}, [
130
+ makeElement('span', 'oe-label', {}, [host.t('Price')]),
131
+ makeElement('div', 'oe-input-grp', {}, [
132
+ makeElement('input', 'oe-input', { type: 'number', 'aria-label': host.t('Price') }, []),
133
+ makeElement('button', 'btn-bbo', { type: 'button', title: host.t('BestBidOrOffer') }, [host.t('BBO')]),
134
+ ]),
135
+ ]),
136
+ makeElement('div', 'oe-field oe-field-stop', {}, [
137
+ makeElement('span', 'oe-label', {}, [host.t('StopPrice')]),
138
+ makeElement('input', 'oe-input', { type: 'number', 'aria-label': host.t('StopPrice') }, []),
139
+ ]),
140
+ makeElement('div', 'oe-field oe-field-qty', {}, [
141
+ makeElement('span', 'oe-label', {}, [host.t('Amount')]),
142
+ makeElement('input', 'oe-input', { type: 'number', 'aria-label': host.t('OrderQuantity') }, []),
143
+ ]),
144
+ makeElement('div', 'oe-pct-row', { role: 'group', 'aria-label': host.t('PercentageOfBalance') }, PERCENT_STEPS.map(pct => makeElement('button', 'btn-oe-pct', { type: 'button', 'data-pct': String(pct) }, [`${pct}%`]))),
145
+ makeElement('div', 'oe-total', {}, [
146
+ makeElement('span', 'oe-label', {}, [host.t('Total')]),
147
+ makeElement('span', 'oe-value', {}, ['--']),
148
+ ]),
149
+ makeElement('label', 'oe-tpsl-toggle', {}, [
150
+ makeElement('input', '', { type: 'checkbox' }, []),
151
+ makeElement('span', '', {}, [host.t('TpSl')]),
152
+ ]),
153
+ makeElement('div', 'oe-tpsl oe-field-hidden', {}, [
154
+ makeElement('div', 'oe-field oe-field-tp', {}, [
155
+ makeElement('span', 'oe-label', {}, [host.t('TakeProfit')]),
156
+ makeElement('input', 'oe-input', { type: 'number', 'aria-label': host.t('TakeProfit') }, []),
157
+ ]),
158
+ makeElement('div', 'oe-field oe-field-sl', {}, [
159
+ makeElement('span', 'oe-label', {}, [host.t('StopLoss')]),
160
+ makeElement('input', 'oe-input', { type: 'number', 'aria-label': host.t('StopLoss') }, []),
161
+ ]),
162
+ ]),
163
+ makeElement('div', 'oe-foot', {}, [
164
+ makeElement('span', 'oe-label', {}, [maxLabel]),
165
+ makeElement('span', 'oe-value', {}, ['--']),
166
+ ]),
167
+ submit,
168
+ makeElement('div', 'oe-estimate', { 'aria-live': 'polite' }, []),
169
+ ]);
170
+ if (isBuy)
171
+ column.className = 'oe-col oe-col-buy';
172
+ else
173
+ column.className = 'oe-col oe-col-sell';
174
+ return column;
175
+ }
176
+ constructor(rootEl, _state, deps) {
177
+ this._host = assertHost(deps?.host, 'OrderEntryWidget');
178
+ for (const name of ['submitOrder']) {
179
+ if (typeof deps?.[name] !== 'function')
180
+ throw new Error(`OrderEntryWidget: dep "${name}" is required`);
181
+ }
182
+ this.rootEl = rootEl;
183
+ this._deps = deps;
184
+ this.el = this.rootEl.querySelector('.order-entry-content');
185
+ this._closeBtn = this.rootEl.querySelector('.panel-close-btn');
186
+ this._cols = {
187
+ buy: this.rootEl.querySelector('.oe-col-buy'),
188
+ sell: this.rootEl.querySelector('.oe-col-sell'),
189
+ };
190
+ this._orderType = OrderEntryTypes.Market;
191
+ this._instrument = null;
192
+ this._lastPrice = 0;
193
+ this._bestBid = 0;
194
+ this._bestAsk = 0;
195
+ this._maxQuantity = { buy: null, sell: null };
196
+ this._focused = null;
197
+ this._enabled = true;
198
+ this._closeBtn?.addEventListener('click', (e) => {
199
+ e.preventDefault();
200
+ this._host.close();
201
+ });
202
+ this._bindTypeTabs();
203
+ for (const side of OrderEntryWidget.SIDES)
204
+ this._bindColumn(side);
205
+ this.setOrderType(OrderEntryTypes.Market);
206
+ this._host.register(this);
207
+ }
208
+ dispose() {
209
+ this._host.unregister(this);
210
+ try {
211
+ this.rootEl.remove();
212
+ }
213
+ catch { /* already detached */ }
214
+ }
215
+ /// The type the form is on. A host reads it before deciding whether a
216
+ /// click-to-trade gesture has to switch the pad over first.
217
+ get orderType() {
218
+ return this._orderType;
219
+ }
220
+ /// The instrument the form is sized and priced against, or null before the
221
+ /// host has named one. Read for its tick by a host that has to quantize a
222
+ /// price of its own before handing it over.
223
+ getInstrument() {
224
+ return this._instrument;
225
+ }
226
+ /// Switch the form's order type and mark the matching tab. The tab lives
227
+ /// under this control's own root, so a host switches type by naming the
228
+ /// type rather than by handing over a button it looked up itself.
229
+ setOrderType(type) {
230
+ this._orderType = type;
231
+ for (const button of this.rootEl.querySelectorAll('.btn-ot')) {
232
+ const tab = button;
233
+ const on = tab.dataset.type === type;
234
+ tab.classList.toggle('active', on);
235
+ tab.setAttribute('aria-pressed', String(on));
236
+ }
237
+ this._applyTypeVisibility();
238
+ for (const side of OrderEntryWidget.SIDES)
239
+ this._recalculate(side);
240
+ }
241
+ /// Adopt the venue's size and price grid: the quantity input steps by the
242
+ /// lot, the price inputs by the tick, and the quantity resets to the minimum
243
+ /// lot — carrying the previous value across a switch yields nonsense like
244
+ /// "10 BTC" after a stock.
245
+ setInstrument(instrument) {
246
+ this._instrument = instrument;
247
+ if (!instrument)
248
+ return;
249
+ const lotSize = Number(instrument.lotSize) || 1;
250
+ const tickSize = Number(instrument.tickSize) || 0.01;
251
+ const minVolume = Number(instrument.minVolume) > 0 ? Number(instrument.minVolume) : lotSize;
252
+ const maxVolume = instrument.maxVolume != null ? Number(instrument.maxVolume) : null;
253
+ for (const side of OrderEntryWidget.SIDES) {
254
+ const qty = this._input(side, QTY_INPUT);
255
+ if (qty) {
256
+ qty.step = String(lotSize);
257
+ qty.min = String(minVolume);
258
+ // The empty string is how an input says "no upper bound".
259
+ qty.max = maxVolume != null ? String(maxVolume) : '';
260
+ qty.value = OrderEntryWidget._formatQty(minVolume);
261
+ }
262
+ for (const selector of [PRICE_INPUT, STOP_INPUT, TP_INPUT, SL_INPUT]) {
263
+ const input = this._input(side, selector);
264
+ if (input)
265
+ input.step = String(tickSize);
266
+ }
267
+ }
268
+ for (const side of OrderEntryWidget.SIDES)
269
+ this._recalculate(side);
270
+ }
271
+ /// The cash this side may still commit. Null reads as "not known yet",
272
+ /// which is not the same as zero.
273
+ setAvailable(side, available) {
274
+ const value = this._cols[side]?.querySelector(AVAILABLE_VALUE);
275
+ if (value)
276
+ value.textContent = formatPrice(available);
277
+ }
278
+ /// The largest quantity this side can trade — what the percent buttons take
279
+ /// a percentage OF. Without it they have no denominator, so they report the
280
+ /// gap through `host.log` rather than inventing one (see `applyPercent`).
281
+ setMaxQuantity(side, max) {
282
+ this._maxQuantity[side] = max != null && isFinite(max) ? max : null;
283
+ const value = this._cols[side]?.querySelector(MAX_VALUE);
284
+ if (value)
285
+ value.textContent = formatQty(this._maxQuantity[side]);
286
+ }
287
+ /// A reference price off the tape. Both columns follow the market while the
288
+ /// form is untouched; the field the trader is typing in is left alone.
289
+ setLimitPrice(price) {
290
+ this._lastPrice = price;
291
+ this._writePrices(this._bestBid > 0 ? this._bestBid : price, this._bestAsk > 0 ? this._bestAsk : price, false);
292
+ }
293
+ /// The same reference price, force-written: an explicit host gesture
294
+ /// ("price this order here") outranks whatever was typed.
295
+ seedLimitPrice(price) {
296
+ this._lastPrice = price;
297
+ this._writePrices(this._bestBid > 0 ? this._bestBid : price, this._bestAsk > 0 ? this._bestAsk : price, true);
298
+ }
299
+ /// Best bid and offer. The passive seed for a limit form is Buy = best bid
300
+ /// and Sell = best ask — joining the book on each side, so the spread in the
301
+ /// form matches the spread in the book. The BBO button next to the field is
302
+ /// the opposite, explicit gesture: take what the other side is showing.
303
+ setBbo(bid, ask) {
304
+ if (bid > 0)
305
+ this._bestBid = bid;
306
+ if (ask > 0)
307
+ this._bestAsk = ask;
308
+ this._writePrices(this._bestBid > 0 ? this._bestBid : this._lastPrice, this._bestAsk > 0 ? this._bestAsk : this._lastPrice, false);
309
+ }
310
+ /// Fill one column's price with the best quote for that direction: Buy takes
311
+ /// the ask, Sell takes the bid.
312
+ applyBbo(side) {
313
+ const bbo = side === OrderEntrySides.Buy ? this._bestAsk : this._bestBid;
314
+ if (!(bbo > 0))
315
+ return;
316
+ this.setPrice(side, bbo);
317
+ }
318
+ /// Force one column's price and let ticks resume updating it afterwards —
319
+ /// what a click on a book level or a chart price means. Quantized to the
320
+ /// instrument's tick, so a raw chart coordinate does not fail validation.
321
+ setPrice(side, price) {
322
+ const input = this._input(side, PRICE_INPUT);
323
+ if (!input)
324
+ return;
325
+ const text = this._formatPriceForInput(price);
326
+ if (!text)
327
+ return;
328
+ input.dataset.userEdited = '0';
329
+ input.value = text;
330
+ this._recalculate(side);
331
+ }
332
+ /// The same quantity in both columns — a quick-size gesture applies to
333
+ /// whichever side the user goes on to press.
334
+ setQuantity(quantity) {
335
+ for (const side of OrderEntryWidget.SIDES) {
336
+ const input = this._input(side, QTY_INPUT);
337
+ if (!input)
338
+ continue;
339
+ input.value = OrderEntryWidget._formatQty(quantity);
340
+ this._recalculate(side);
341
+ }
342
+ }
343
+ /// Take a percentage of this side's maximum quantity, rounded down to a
344
+ /// whole number of lots.
345
+ applyPercent(side, pct) {
346
+ const max = this._maxQuantity[side];
347
+ if (max == null) {
348
+ this._host.log(`OrderEntryWidget: no maximum quantity for the ${side} side, so ${pct}% of it is unknown`);
349
+ return;
350
+ }
351
+ const qty = this._input(side, QTY_INPUT);
352
+ if (!qty)
353
+ return;
354
+ const step = Number(qty.step) || 0;
355
+ let target = max * (pct / 100);
356
+ if (step > 0)
357
+ target = Math.floor(target / step) * step;
358
+ qty.value = OrderEntryWidget._formatQty(target);
359
+ this._recalculate(side);
360
+ }
361
+ /// Show or hide one column's take-profit / stop-loss block, checkbox
362
+ /// included, so a host can set it without reaching for the input.
363
+ toggleTpSl(side, on) {
364
+ const check = this._input(side, TPSL_CHECK);
365
+ if (check)
366
+ check.checked = on;
367
+ this._show(this._cols[side]?.querySelector('.oe-tpsl'), on);
368
+ }
369
+ /// Mark one column's submit button as the one a host-level gesture aimed at.
370
+ /// Null clears both.
371
+ preselect(side) {
372
+ for (const each of OrderEntryWidget.SIDES) {
373
+ const button = this._cols[each]?.querySelector('.btn-oe-submit');
374
+ button?.classList.toggle('btn-preselected', each === side);
375
+ }
376
+ }
377
+ /// Turn submitting off while the host cannot send — a dropped socket, a
378
+ /// portfolio still loading. The form stays readable and editable; only the
379
+ /// two buttons stop.
380
+ setEnabled(enabled) {
381
+ this._enabled = enabled;
382
+ this.el?.classList.toggle('oe-offline', !enabled);
383
+ for (const side of OrderEntryWidget.SIDES)
384
+ this._validateSide(side);
385
+ }
386
+ /// The submit button, and whatever host gesture stands in for it. Validates
387
+ /// first, so a form the venue would reject never reaches the account.
388
+ submit(side) {
389
+ const error = this.validate(side);
390
+ this._validateSide(side);
391
+ if (error || !this._enabled)
392
+ return;
393
+ this._deps.submitOrder(side, this.getValues(side));
394
+ }
395
+ /// What one column currently says.
396
+ getValues(side) {
397
+ const type = this._orderType;
398
+ const carriesLimit = type === OrderEntryTypes.Limit || type === OrderEntryTypes.StopLimit;
399
+ const carriesStop = type === OrderEntryTypes.Stop || type === OrderEntryTypes.StopLimit;
400
+ const tpSlOn = this._input(side, TPSL_CHECK)?.checked === true;
401
+ return {
402
+ type,
403
+ quantity: this._number(side, QTY_INPUT) ?? 0,
404
+ limitPrice: carriesLimit ? this._number(side, PRICE_INPUT) : null,
405
+ stopPrice: carriesStop ? this._number(side, STOP_INPUT) : null,
406
+ takeProfit: tpSlOn ? this._number(side, TP_INPUT) : null,
407
+ stopLoss: tpSlOn ? this._number(side, SL_INPUT) : null,
408
+ };
409
+ }
410
+ /// The reason this column cannot be sent, worded by the host, or null when
411
+ /// it can. Nothing is checked before the instrument is known — the venue's
412
+ /// grid is what every rule below measures against.
413
+ validate(side) {
414
+ const instrument = this._instrument;
415
+ if (!instrument)
416
+ return null;
417
+ const { quantity, limitPrice, stopPrice, type } = this.getValues(side);
418
+ if (quantity <= 0)
419
+ return this._host.t('Quantity must be positive');
420
+ const lotSize = Number(instrument.lotSize) || 0;
421
+ if (lotSize > 0 && !OrderEntryWidget._isMultipleOf(quantity, lotSize))
422
+ return this._host.t('Quantity must be a multiple of {0}', lotSize);
423
+ const minVolume = Number(instrument.minVolume) > 0 ? Number(instrument.minVolume) : lotSize;
424
+ if (minVolume > 0 && quantity < minVolume)
425
+ return this._host.t('Quantity must be >= {0}', minVolume);
426
+ if (instrument.maxVolume != null && quantity > Number(instrument.maxVolume))
427
+ return this._host.t('Quantity must be <= {0}', instrument.maxVolume);
428
+ const tickSize = Number(instrument.tickSize) || 0;
429
+ if (type === OrderEntryTypes.Limit || type === OrderEntryTypes.StopLimit) {
430
+ if (!limitPrice || limitPrice <= 0)
431
+ return this._host.t('Limit price must be positive');
432
+ if (tickSize > 0 && !OrderEntryWidget._isMultipleOf(limitPrice, tickSize))
433
+ return this._host.t('Limit price must be a multiple of {0}', tickSize);
434
+ }
435
+ if (type === OrderEntryTypes.Stop || type === OrderEntryTypes.StopLimit) {
436
+ if (!stopPrice || stopPrice <= 0)
437
+ return this._host.t('Stop price must be positive');
438
+ if (tickSize > 0 && !OrderEntryWidget._isMultipleOf(stopPrice, tickSize))
439
+ return this._host.t('Stop price must be a multiple of {0}', tickSize);
440
+ }
441
+ return null;
442
+ }
443
+ /// Map a form type onto StockSharp's `OrderTypes`: Limit=0, Market=1,
444
+ /// Conditional=2. Static because a host needs it to word the order it is
445
+ /// about to send, which it can do before any instance exists.
446
+ static toApiType(uiType) {
447
+ switch (uiType) {
448
+ case OrderEntryTypes.Limit: return 0;
449
+ case OrderEntryTypes.Market: return 1;
450
+ case OrderEntryTypes.Stop:
451
+ case OrderEntryTypes.StopLimit: return 2;
452
+ default: return 1;
453
+ }
454
+ }
455
+ /// Is `value` a whole number of `step`s? Scaled to integers first: a crypto
456
+ /// lot of 1e-9 against a quantity of 0.001 is not a modulo binary floats can
457
+ /// answer directly.
458
+ static _isMultipleOf(value, step) {
459
+ if (!(step > 0))
460
+ return true;
461
+ if (value === 0)
462
+ return true;
463
+ const maxDec = Math.max(OrderEntryWidget._decimals(step), OrderEntryWidget._decimals(value));
464
+ const scale = Math.pow(10, maxDec);
465
+ const scaledValue = Math.round(value * scale);
466
+ const scaledStep = Math.round(step * scale);
467
+ if (scaledStep <= 0)
468
+ return true;
469
+ if (scaledValue === 0)
470
+ return false;
471
+ return scaledValue % scaledStep === 0;
472
+ }
473
+ /// A quantity as an input's `value`: every significant decimal, no thousands
474
+ /// separator and no exponent, because the browser reads this string back as
475
+ /// a number.
476
+ static _formatQty(n) {
477
+ if (!isFinite(n) || n === 0)
478
+ return '0';
479
+ const d = OrderEntryWidget._decimals(n);
480
+ return n.toFixed(Math.min(d, 20));
481
+ }
482
+ /// How many decimals a number is written with, exponent form included.
483
+ static _decimals(n) {
484
+ if (!isFinite(n) || n === 0)
485
+ return 0;
486
+ const s = Math.abs(n).toString();
487
+ const eIdx = s.indexOf('e-');
488
+ if (eIdx >= 0) {
489
+ const mantissaDec = s.slice(0, eIdx).split('.')[1]?.length ?? 0;
490
+ return parseInt(s.slice(eIdx + 2), 10) + mantissaDec;
491
+ }
492
+ const dot = s.indexOf('.');
493
+ return dot < 0 ? 0 : s.length - dot - 1;
494
+ }
495
+ _bindTypeTabs() {
496
+ for (const button of this.rootEl.querySelectorAll('.btn-ot')) {
497
+ const tab = button;
498
+ tab.addEventListener('click', (e) => {
499
+ e.preventDefault();
500
+ const type = tab.dataset.type;
501
+ if (type)
502
+ this.setOrderType(type);
503
+ });
504
+ }
505
+ }
506
+ // Everything one column listens to. Scoped to the column element, which is
507
+ // what lets two pads coexist: an input is found by the field it belongs to
508
+ // under its own root, never by a page-wide id.
509
+ _bindColumn(side) {
510
+ const column = this._cols[side];
511
+ if (!column)
512
+ return;
513
+ const qty = this._input(side, QTY_INPUT);
514
+ if (qty) {
515
+ qty.value = '1';
516
+ qty.min = '1';
517
+ qty.step = '1';
518
+ }
519
+ for (const selector of VALUE_INPUTS) {
520
+ const input = this._input(side, selector);
521
+ if (!input)
522
+ continue;
523
+ input.addEventListener('input', () => this._recalculate(side));
524
+ input.addEventListener('focus', () => { this._focused = input; });
525
+ input.addEventListener('blur', () => { if (this._focused === input)
526
+ this._focused = null; });
527
+ // Mark a price the trader typed, so an incoming tick stops
528
+ // overwriting it mid-keystroke.
529
+ if (selector === PRICE_INPUT || selector === STOP_INPUT)
530
+ input.addEventListener('input', () => { input.dataset.userEdited = '1'; });
531
+ }
532
+ column.querySelector('.btn-bbo')?.addEventListener('click', (e) => {
533
+ e.preventDefault();
534
+ this.applyBbo(side);
535
+ });
536
+ for (const button of column.querySelectorAll('.btn-oe-pct')) {
537
+ const pct = button;
538
+ pct.addEventListener('click', (e) => {
539
+ e.preventDefault();
540
+ this.applyPercent(side, Number(pct.dataset.pct) || 0);
541
+ });
542
+ }
543
+ const check = this._input(side, TPSL_CHECK);
544
+ check?.addEventListener('change', () => this.toggleTpSl(side, check.checked === true));
545
+ column.querySelector('.btn-oe-submit')?.addEventListener('click', (e) => {
546
+ e.preventDefault();
547
+ this.submit(side);
548
+ });
549
+ }
550
+ // Which price fields the current type makes meaningful. A class toggle, not
551
+ // a display style: the pad emits names and the stylesheet decides.
552
+ _applyTypeVisibility() {
553
+ const showLimit = this._orderType === OrderEntryTypes.Limit || this._orderType === OrderEntryTypes.StopLimit;
554
+ const showStop = this._orderType === OrderEntryTypes.Stop || this._orderType === OrderEntryTypes.StopLimit;
555
+ for (const side of OrderEntryWidget.SIDES) {
556
+ this._show(this._cols[side]?.querySelector('.oe-field-price'), showLimit);
557
+ this._show(this._cols[side]?.querySelector('.oe-field-stop'), showStop);
558
+ }
559
+ }
560
+ _show(element, visible) {
561
+ element?.classList.toggle('oe-field-hidden', !visible);
562
+ }
563
+ // Write both price inputs. `reset` is the explicit gesture: it clears the
564
+ // "typed in" mark so the field follows the market again. Without it, a field
565
+ // the user is in or has already edited is left alone.
566
+ _writePrices(buyPrice, sellPrice, reset) {
567
+ this._writePrice(OrderEntrySides.Buy, buyPrice, reset);
568
+ this._writePrice(OrderEntrySides.Sell, sellPrice, reset);
569
+ for (const side of OrderEntryWidget.SIDES)
570
+ this._recalculate(side);
571
+ }
572
+ _writePrice(side, price, reset) {
573
+ const input = this._input(side, PRICE_INPUT);
574
+ if (!input)
575
+ return;
576
+ const text = this._formatPriceForInput(price);
577
+ if (!text)
578
+ return;
579
+ if (reset) {
580
+ input.dataset.userEdited = '0';
581
+ input.value = text;
582
+ return;
583
+ }
584
+ if (this._focused === input)
585
+ return;
586
+ if (input.dataset.userEdited === '1')
587
+ return;
588
+ input.value = text;
589
+ }
590
+ // Round a price to the instrument's tick and cut the float tail: a mid price
591
+ // of (bid+ask)/2 otherwise reaches the field as 0.44625000000000004.
592
+ _formatPriceForInput(price) {
593
+ const n = Number(price);
594
+ if (!isFinite(n))
595
+ return '';
596
+ const tickSize = Number(this._instrument?.tickSize) || 0;
597
+ if (tickSize > 0)
598
+ return (Math.round(n / tickSize) * tickSize).toFixed(OrderEntryWidget._decimals(tickSize));
599
+ return String(parseFloat(n.toPrecision(12)));
600
+ }
601
+ _recalculate(side) {
602
+ this._updateEstimate(side);
603
+ this._validateSide(side);
604
+ }
605
+ // What the order is worth at the price it would most likely fill at: the
606
+ // limit or stop the form names, else the side-appropriate BBO, else the last
607
+ // print.
608
+ _updateEstimate(side) {
609
+ const total = this._cols[side]?.querySelector(TOTAL_VALUE);
610
+ if (!total)
611
+ return;
612
+ const quantity = this._number(side, QTY_INPUT) ?? 0;
613
+ let price;
614
+ if (this._orderType === OrderEntryTypes.Limit || this._orderType === OrderEntryTypes.StopLimit) {
615
+ price = this._number(side, PRICE_INPUT) ?? 0;
616
+ }
617
+ else if (this._orderType === OrderEntryTypes.Stop) {
618
+ price = this._number(side, STOP_INPUT) ?? this._lastPrice;
619
+ }
620
+ else {
621
+ const bbo = side === OrderEntrySides.Buy ? this._bestAsk : this._bestBid;
622
+ price = bbo > 0 ? bbo : this._lastPrice;
623
+ }
624
+ total.textContent = quantity > 0 && price > 0
625
+ ? (quantity * price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
626
+ : '--';
627
+ }
628
+ // The estimate line doubles as the validation message and the submit button
629
+ // follows it. A disabled pad reads as invalid for the same reason: there is
630
+ // nothing the button could do.
631
+ _validateSide(side) {
632
+ const error = this.validate(side);
633
+ const estimate = this._cols[side]?.querySelector('.oe-estimate');
634
+ const submit = this._cols[side]?.querySelector('.btn-oe-submit');
635
+ if (estimate) {
636
+ estimate.textContent = error || '';
637
+ estimate.classList.toggle('oe-estimate-error', !!error);
638
+ }
639
+ if (submit)
640
+ submit.disabled = !!error || !this._enabled;
641
+ }
642
+ _input(side, selector) {
643
+ return this._cols[side]?.querySelector(selector) ?? null;
644
+ }
645
+ // An input's value as a number, or null when it is blank or unparseable —
646
+ // which is what "the user has not said" looks like on a number field.
647
+ _number(side, selector) {
648
+ const parsed = parseFloat(this._input(side, selector)?.value ?? '');
649
+ return isFinite(parsed) ? parsed : null;
650
+ }
651
+ }
652
+ OrderEntryWidget.TYPE = ControlTypes.OrderEntry;
653
+ OrderEntryWidget.SIDES = [OrderEntrySides.Buy, OrderEntrySides.Sell];
654
+ //# sourceMappingURL=order-entry-widget.js.map