@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.
- package/README.md +92 -6
- package/dist/esm/active-orders-widget.js +9 -0
- package/dist/esm/active-orders-widget.js.map +1 -1
- package/dist/esm/control-types.js +3 -0
- package/dist/esm/control-types.js.map +1 -1
- package/dist/esm/grid-menu.js +54 -0
- package/dist/esm/grid-menu.js.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/order-entry-widget.js +654 -0
- package/dist/esm/order-entry-widget.js.map +1 -0
- package/dist/esm/orderbook-depth.js +51 -0
- package/dist/esm/orderbook-depth.js.map +1 -0
- package/dist/esm/orderbook-widget.js +792 -0
- package/dist/esm/orderbook-widget.js.map +1 -0
- package/dist/esm/positions-widget.js +9 -0
- package/dist/esm/positions-widget.js.map +1 -1
- package/dist/esm/trade-history-widget.js +8 -0
- package/dist/esm/trade-history-widget.js.map +1 -1
- package/dist/esm/tradefeed-aggregator.js +58 -0
- package/dist/esm/tradefeed-aggregator.js.map +1 -0
- package/dist/esm/tradefeed-bubbles.js +131 -0
- package/dist/esm/tradefeed-bubbles.js.map +1 -0
- package/dist/esm/tradefeed-widget.js +692 -0
- package/dist/esm/tradefeed-widget.js.map +1 -0
- package/dist/esm/trading-host.js +2 -0
- package/dist/esm/trading-host.js.map +1 -1
- package/dist/esm/watchlist-widget.js +2 -0
- package/dist/esm/watchlist-widget.js.map +1 -1
- package/dist/sstradingcontrols.js +3346 -25
- package/dist/sstradingcontrols.js.map +3 -3
- package/dist/types/active-orders-widget.d.ts.map +1 -1
- package/dist/types/control-types.d.ts +3 -0
- package/dist/types/control-types.d.ts.map +1 -1
- package/dist/types/grid-menu.d.ts +4 -0
- package/dist/types/grid-menu.d.ts.map +1 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/order-entry-widget.d.ts +86 -0
- package/dist/types/order-entry-widget.d.ts.map +1 -0
- package/dist/types/orderbook-depth.d.ts +19 -0
- package/dist/types/orderbook-depth.d.ts.map +1 -0
- package/dist/types/orderbook-widget.d.ts +105 -0
- package/dist/types/orderbook-widget.d.ts.map +1 -0
- package/dist/types/positions-widget.d.ts.map +1 -1
- package/dist/types/trade-history-widget.d.ts.map +1 -1
- package/dist/types/tradefeed-aggregator.d.ts +15 -0
- package/dist/types/tradefeed-aggregator.d.ts.map +1 -0
- package/dist/types/tradefeed-bubbles.d.ts +39 -0
- package/dist/types/tradefeed-bubbles.d.ts.map +1 -0
- package/dist/types/tradefeed-widget.d.ts +78 -0
- package/dist/types/tradefeed-widget.d.ts.map +1 -0
- package/dist/types/trading-data.d.ts +22 -0
- package/dist/types/trading-data.d.ts.map +1 -1
- package/dist/types/trading-host.d.ts +8 -0
- package/dist/types/trading-host.d.ts.map +1 -1
- package/dist/types/watchlist-widget.d.ts.map +1 -1
- package/package.json +21 -2
- package/screenshots/panels.jpg +0 -0
- package/src/active-orders-widget.ts +9 -0
- package/src/control-types.ts +3 -0
- package/src/grid-menu.ts +67 -0
- package/src/index.ts +31 -0
- package/src/order-entry-widget.ts +733 -0
- package/src/orderbook-depth.ts +99 -0
- package/src/orderbook-widget.ts +896 -0
- package/src/positions-widget.ts +9 -0
- package/src/trade-history-widget.ts +8 -0
- package/src/tradefeed-aggregator.ts +95 -0
- package/src/tradefeed-bubbles.ts +194 -0
- package/src/tradefeed-widget.ts +744 -0
- package/src/trading-data.ts +168 -121
- package/src/trading-host.ts +326 -300
- package/src/watchlist-widget.ts +2 -0
- package/styles/theme.css +23 -0
- package/styles/trading-controls.css +911 -0
- 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
|